Skip to content

Commit 90ca889

Browse files
stainless-app[bot]NiteshDhanpalclaudelevilentz
authored
chore: release main (#461)
Co-authored-by: Nitesh Dhanpal <NiteshDhanpal@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> Co-authored-by: Levi Lentz <levi.lentz@scale.com>
1 parent 0afaae4 commit 90ca889

29 files changed

Lines changed: 1097 additions & 474 deletions

File tree

.release-please-manifest.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
{
2-
".": "0.18.0",
3-
"adk": "0.18.0"
2+
".": "0.19.0",
3+
"adk": "0.19.0"
44
}

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,20 @@
1212

1313
* **tracing:** emit OTel metrics for async span queue depth, batch drain, and SGP export success/failure (HTTP status labels). Disable SDK-side recording with ``AGENTEX_TRACING_METRICS=0``.
1414

15+
## 0.19.0 (2026-07-14)
16+
17+
Full Changelog: [agentex-client-v0.18.0...agentex-client-v0.19.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-client-v0.18.0...agentex-client-v0.19.0)
18+
19+
### Features
20+
21+
* **tracing:** emit token usage on spans for SGP billing ([#458](https://github.com/scaleapi/scale-agentex-python/issues/458)) ([7d19ada](https://github.com/scaleapi/scale-agentex-python/commit/7d19ada2db5d1eca5268a10fe04dfc85a367cf7f))
22+
23+
24+
### Bug Fixes
25+
26+
* **internal:** resolve build failures ([9245b70](https://github.com/scaleapi/scale-agentex-python/commit/9245b700ceee95be9a0c478e518d9c06228d4b9f))
27+
* **tracing:** capture span body exceptions and export SGP status=ERROR ([#460](https://github.com/scaleapi/scale-agentex-python/issues/460)) ([6c23d76](https://github.com/scaleapi/scale-agentex-python/commit/6c23d7625ccf58ac9793dcf5219e4f7f4de38353))
28+
1529
## 0.18.0 (2026-07-10)
1630

1731
Full Changelog: [agentex-client-v0.17.0...agentex-client-v0.18.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-client-v0.17.0...agentex-client-v0.18.0)

adk/CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# Changelog
22

3+
## 0.19.0 (2026-07-14)
4+
5+
Full Changelog: [agentex-sdk-v0.18.0...agentex-sdk-v0.19.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-sdk-v0.18.0...agentex-sdk-v0.19.0)
6+
7+
### Chores
8+
9+
* **agentex-sdk:** Synchronize agentex versions
10+
311
## 0.18.0 (2026-07-10)
412

513
Full Changelog: [agentex-sdk-v0.17.0...agentex-sdk-v0.18.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-sdk-v0.17.0...agentex-sdk-v0.18.0)

adk/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
# (agentex/{__init__.py, _*.py, types/, resources/}) ships from the slim
55
# sibling package `agentex-client` which is pinned as a runtime dep.
66
name = "agentex-sdk"
7-
version = "0.18.0"
7+
version = "0.19.0"
88
description = "Agent Development Kit (ADK) overlay for the Agentex API — FastACP server, Temporal workflows, LLM provider integrations, observability"
99
license = "Apache-2.0"
1010
authors = [

examples/tutorials/10_async/00_base/030_tracing/README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,39 @@ await adk.tracing.end_span(
4141

4242
Spans create a hierarchical view of agent execution, making it easy to see which operations take time and where errors occur.
4343

44+
## Token Usage & Cost Tracking
45+
46+
Token usage on spans is what the backend bills from, and it reads two shapes:
47+
48+
- **Per-turn aggregate**`span.data["usage"]` + `span.data["cost_usd"]`. Emit at most
49+
once per turn, holding that turn's own usage (not a session-cumulative total). When a
50+
trace has an aggregate, the backend keeps it and de-dups all per-call spans against it.
51+
- **Per-call detail**`span.output["usage"]`. Optional; the SDK's LLM adapters
52+
(litellm, OpenAI Agents SDK, LangGraph) emit this automatically. Summed only when no
53+
aggregate exists in the trace.
54+
55+
Record the turn rollup with `adk.tracing.turn_span()` instead of hand-writing usage keys.
56+
It accepts the harness `TurnUsage` that every turn adapter reports (`LangGraphTurn.usage()`,
57+
`run_turn(...).usage`, `ClaudeCodeTurn.usage()`, ...), cost included:
58+
59+
```python
60+
async with adk.tracing.turn_span(
61+
trace_id=task.id,
62+
name="turn",
63+
input={"prompt": prompt},
64+
task_id=task.id,
65+
) as turn:
66+
result = await run_turn(...)
67+
turn.output = {"response": result.final_output}
68+
turn.record_usage(result.usage) # TurnUsage; cost_usd stamped automatically
69+
```
70+
71+
**Never put usage on both a rollup span's `output` and its per-call children's
72+
`output`** — that double-counts. `turn_span` writes the aggregate to `data`, so child
73+
spans stay safe to emit. Recognized token keys: `input_tokens`/`prompt_tokens`,
74+
`output_tokens`/`completion_tokens`, `cached_input_tokens`/`cached_tokens`,
75+
`reasoning_tokens`; cost is `cost_usd`.
76+
4477
## When to Use
4578
- Debugging complex agent behaviors
4679
- Performance optimization and bottleneck identification

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
# overlay (formerly `src/agentex/lib/*`) now lives in `adk/` and ships
44
# as the sibling `agentex-sdk` package — see `adk/pyproject.toml`.
55
name = "agentex-client"
6-
version = "0.18.0"
6+
version = "0.19.0"
77
description = "The official Python REST client for the Agentex API"
88
dynamic = ["readme"]
99
license = "Apache-2.0"

scripts/lint

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ else
1313
fi
1414

1515
echo "==> Running pyright"
16-
uv run pyright
16+
uv run pyright -p .
1717

1818
echo "==> Making sure it imports"
1919
uv run python -c 'import agentex'

src/agentex/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
22

33
__title__ = "agentex"
4-
__version__ = "0.18.0" # x-release-please-version
4+
__version__ = "0.19.0" # x-release-please-version

src/agentex/lib/adk/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
from agentex.lib.adk._modules.state import StateModule
2828
from agentex.lib.adk._modules.streaming import StreamingModule
2929
from agentex.lib.adk._modules.tasks import TasksModule
30-
from agentex.lib.adk._modules.tracing import TracingModule
30+
from agentex.lib.adk._modules.tracing import TracingModule, TurnSpan
3131

3232
# Unified harness surface (AGX1-375)
3333
from agentex.lib.core.harness import (
@@ -66,6 +66,7 @@
6666
"tracing",
6767
"events",
6868
"agent_task_tracker",
69+
"TurnSpan",
6970
# Checkpointing / LangGraph
7071
"create_checkpointer",
7172
"stream_langgraph_events",

src/agentex/lib/adk/_modules/tracing.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
TracingActivityName,
2121
)
2222
from agentex.lib.core.tracing.tracer import AsyncTracer
23+
from agentex.lib.core.harness.types import TurnUsage
2324
from agentex.types.span import Span
2425
from agentex.lib.utils.logging import make_logger
2526
from agentex.lib.utils.model_utils import BaseModel
@@ -30,6 +31,21 @@
3031
DEFAULT_RETRY_POLICY = RetryPolicy(maximum_attempts=1)
3132
TEMPORAL_SPAN_ACTIVITY_DROPPED_METRIC = "agentex.tracing.temporal_span_activity.dropped"
3233

34+
# Token key spellings the backend accepts when billing usage from spans.
35+
RECOGNIZED_USAGE_KEYS = frozenset(
36+
{
37+
"input_tokens",
38+
"prompt_tokens",
39+
"output_tokens",
40+
"completion_tokens",
41+
"cached_input_tokens",
42+
"cached_tokens",
43+
"reasoning_tokens",
44+
"total_tokens",
45+
"cost_usd",
46+
}
47+
)
48+
3349

3450
def _record_temporal_span_activity_dropped(event_type: str) -> None:
3551
try:
@@ -42,6 +58,80 @@ def _record_temporal_span_activity_dropped(event_type: str) -> None:
4258
pass
4359

4460

61+
class TurnSpan:
62+
"""Handle for a turn-level (rollup) span, yielded by ``TracingModule.turn_span``.
63+
64+
Encapsulates the billing contract so agents cannot double-count usage:
65+
the turn's aggregate usage goes to ``span.data["usage"]`` (+
66+
``span.data["cost_usd"]``) via :meth:`record_usage`. The backend keeps the
67+
aggregate and de-dups any per-call ``output["usage"]`` children against it.
68+
Never hand-write usage into ``output`` on a rollup span — that is the
69+
double-count bug this helper exists to prevent.
70+
71+
All methods no-op when tracing is disabled (``span`` is None), so agent
72+
code needs no ``if span:`` guards.
73+
"""
74+
75+
def __init__(self, span: Span | None):
76+
self.span = span
77+
78+
def record_usage(
79+
self,
80+
usage: TurnUsage | dict[str, Any] | None = None,
81+
cost_usd: float | None = None,
82+
) -> None:
83+
"""Record the turn's aggregate usage on the span's ``data``.
84+
85+
Pass the harness ``TurnUsage`` (e.g. ``LangGraphTurn.usage()`` or
86+
``run_turn(...).usage``) — its ``cost_usd`` is stamped automatically —
87+
or a plain dict with backend-recognized token spellings
88+
(``prompt_tokens``/``completion_tokens`` also work). An explicit
89+
``cost_usd`` argument overrides any cost carried by ``usage``. The
90+
usage must be this turn's own tokens, not a session-cumulative total.
91+
"""
92+
if self.span is None:
93+
return
94+
95+
blob: dict[str, Any]
96+
if isinstance(usage, TurnUsage):
97+
blob = usage.model_dump(exclude_none=True)
98+
# cost lives beside the blob as data["cost_usd"], not inside it
99+
blob_cost = blob.pop("cost_usd", None)
100+
if cost_usd is None:
101+
cost_usd = blob_cost
102+
elif usage is not None:
103+
blob = dict(usage)
104+
if not any(key in RECOGNIZED_USAGE_KEYS for key in blob):
105+
logger.warning(
106+
"TurnSpan.record_usage: usage has no recognized token keys and will "
107+
f"not be billed. Got keys {sorted(blob)}; expected any of "
108+
f"{sorted(RECOGNIZED_USAGE_KEYS)}."
109+
)
110+
else:
111+
blob = {}
112+
113+
if self.span.data is not None and not isinstance(self.span.data, dict):
114+
logger.warning(
115+
f"TurnSpan.record_usage: span.data is {type(self.span.data).__name__} "
116+
"(expected dict or None); existing data will be replaced."
117+
)
118+
data = self.span.data if isinstance(self.span.data, dict) else {}
119+
if blob:
120+
data["usage"] = blob
121+
if cost_usd is not None:
122+
data["cost_usd"] = cost_usd
123+
self.span.data = data
124+
125+
@property
126+
def output(self) -> Any:
127+
return self.span.output if self.span is not None else None
128+
129+
@output.setter
130+
def output(self, value: Any) -> None:
131+
if self.span is not None:
132+
self.span.output = value
133+
134+
45135
class TracingModule:
46136
"""
47137
Module for managing tracing and span operations in Agentex.
@@ -156,6 +246,47 @@ async def span(
156246
retry_policy=retry_policy,
157247
)
158248

249+
@asynccontextmanager
250+
async def turn_span(
251+
self,
252+
trace_id: str,
253+
name: str,
254+
input: list[Any] | dict[str, Any] | BaseModel | None = None,
255+
data: list[Any] | dict[str, Any] | BaseModel | None = None,
256+
parent_id: str | None = None,
257+
task_id: str | None = None,
258+
start_to_close_timeout: timedelta = timedelta(seconds=5),
259+
heartbeat_timeout: timedelta = timedelta(seconds=5),
260+
retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY,
261+
) -> AsyncGenerator[TurnSpan, None]:
262+
"""Span for one agent turn, with usage recorded as the billable aggregate.
263+
264+
Same lifecycle as :meth:`span`, but yields a :class:`TurnSpan` whose
265+
``record_usage(usage=..., cost_usd=...)`` writes the turn's rollup
266+
usage to ``span.data`` — the shape the backend bills once per turn.
267+
Per-call child spans (LLM adapters) may still carry
268+
``output["usage"]``; the backend de-dups them against this aggregate.
269+
270+
Example (with a harness turn, e.g. ``LangGraphTurn`` / ``run_turn``)::
271+
272+
async with adk.tracing.turn_span(trace_id=task.id, name="turn", input={...}, task_id=task.id) as turn:
273+
result = await run_turn(...)
274+
turn.output = {"response": result.final_output}
275+
turn.record_usage(result.usage) # TurnUsage, cost_usd included
276+
"""
277+
async with self.span(
278+
trace_id=trace_id,
279+
name=name,
280+
input=input,
281+
data=data,
282+
parent_id=parent_id,
283+
task_id=task_id,
284+
start_to_close_timeout=start_to_close_timeout,
285+
heartbeat_timeout=heartbeat_timeout,
286+
retry_policy=retry_policy,
287+
) as span:
288+
yield TurnSpan(span)
289+
159290
async def start_span(
160291
self,
161292
trace_id: str,

0 commit comments

Comments
 (0)