Metadata-Version: 2.4
Name: tencentcloud-agentobs-sdk-agentscope
Version: 0.1.3
Summary: Tencent Cloud CLS observability for AgentScope agents
License: Apache-2.0
Project-URL: Homepage, https://github.com/TencentCloud/tencentcloud-agentobs-sdk-agentscope
Project-URL: Issues, https://github.com/TencentCloud/tencentcloud-agentobs-sdk-agentscope/issues
Keywords: agentscope,opentelemetry,observability,cls,tencentcloud,llm
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: System :: Monitoring
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: opentelemetry-api>=1.20.0
Requires-Dist: opentelemetry-sdk>=1.20.0
Requires-Dist: opentelemetry-instrumentation>=0.41b0
Requires-Dist: wrapt>=1.14.0
Requires-Dist: tencentcloud-cls-sdk-python>=1.0.0
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == "dev"
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
Dynamic: license-file

# tencentcloud-agentobs-sdk-agentscope

Traces [AgentScope](https://github.com/modelscope/agentscope) agents into
Tencent Cloud CLS, following OpenTelemetry GenAI semantic conventions.

[简体中文](README.zh-CN.md)

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

```bash
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:

```bash
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=...
```

```python
from tencentcloud_agentobs_sdk_agentscope import init

init()  # reads the CLS_* variables above
```

Or pass them directly — equivalent:

```python
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](#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.

```python
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):

```json
{
  "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](#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:

```python
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:

```bash
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:

```python
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:

```python
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:

```bash
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:

```bash
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

```python
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:

```python
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:

```python
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](#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

```bash
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:

```bash
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:

```bash
.venv-as1/bin/python examples/readme_demo.py
```

`examples/quickstart.py` — the minimal loop under either major, printing full CLS
span JSON:

```bash
.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:

```bash
.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
