Skip to main content

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 upload support is included by default.

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 (CLS SDK issue, say) → also degrades to stdout with a warning. Observability should not take an application down. Constructing CLSCloudExporter yourself 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 ERROR on 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

tencentcloud_agentobs_sdk_agentscope-0.1.5.tar.gz (112.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

File details

Details for the file tencentcloud_agentobs_sdk_agentscope-0.1.5.tar.gz.

File metadata

File hashes

Hashes for tencentcloud_agentobs_sdk_agentscope-0.1.5.tar.gz
Algorithm Hash digest
SHA256 09ae32a65bd4d9e89655a366d4640196ce83e309ed691bb62b017eb3c41f6d21
MD5 9908c065ae60d2491c7fcc535d62ea43
BLAKE2b-256 871125836ae190e6149999f3fe8d029a5bf76a737e82b5077a0272e596c07b34

See more details on using hashes here.

File details

Details for the file tencentcloud_agentobs_sdk_agentscope-0.1.5-py3-none-any.whl.

File metadata

File hashes

Hashes for tencentcloud_agentobs_sdk_agentscope-0.1.5-py3-none-any.whl
Algorithm Hash digest
SHA256 678276f0a7acb2f0f69206a9ee476c4da8282d7fbd2bd397952e1813f08ae844
MD5 88529fb6534588cc31c1d21481cd7c5e
BLAKE2b-256 da25f2c84a46ee78bb1e2ae4d31ce4d8590b1563430a7e34e7f72f44909d407d

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

0.1.5 This release

2 files

0.1.4

2 files

0.1.3

2 files

0.1.2

2 files

0.1.1

2 files

0.1.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page