Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
76e0299
chore(internal): reformat pyproject.toml
stainless-app[bot] May 4, 2026
374dd9a
codegen metadata
stainless-app[bot] Apr 30, 2026
ce99568
codegen metadata
stainless-app[bot] Apr 30, 2026
781fa60
codegen metadata
stainless-app[bot] Apr 30, 2026
d807d4a
codegen metadata
stainless-app[bot] May 1, 2026
1a823ac
codegen metadata
stainless-app[bot] May 1, 2026
2f4f8ff
codegen metadata
stainless-app[bot] May 1, 2026
09ccff7
codegen metadata
stainless-app[bot] May 1, 2026
481fc64
codegen metadata
stainless-app[bot] May 1, 2026
751e6e2
codegen metadata
stainless-app[bot] May 1, 2026
7d52c8b
codegen metadata
stainless-app[bot] May 1, 2026
dc26b20
codegen metadata
stainless-app[bot] May 1, 2026
d3d2fd3
codegen metadata
stainless-app[bot] May 1, 2026
604484a
codegen metadata
stainless-app[bot] May 2, 2026
fb2b652
codegen metadata
stainless-app[bot] May 2, 2026
f9e100d
codegen metadata
stainless-app[bot] May 2, 2026
d2c3873
codegen metadata
stainless-app[bot] May 2, 2026
b4ac845
codegen metadata
stainless-app[bot] May 2, 2026
1f81349
codegen metadata
stainless-app[bot] May 2, 2026
c020912
codegen metadata
stainless-app[bot] May 2, 2026
8c93c46
codegen metadata
stainless-app[bot] May 2, 2026
d458944
codegen metadata
stainless-app[bot] May 2, 2026
50e7c83
codegen metadata
stainless-app[bot] May 2, 2026
6627005
codegen metadata
stainless-app[bot] May 2, 2026
a0ff612
codegen metadata
stainless-app[bot] May 2, 2026
3ef6804
codegen metadata
stainless-app[bot] May 2, 2026
a3d0650
codegen metadata
stainless-app[bot] May 3, 2026
6ba71ee
codegen metadata
stainless-app[bot] May 3, 2026
4d15e72
codegen metadata
stainless-app[bot] May 3, 2026
34d770c
codegen metadata
stainless-app[bot] May 3, 2026
4218e01
codegen metadata
stainless-app[bot] May 3, 2026
3b8adfd
codegen metadata
stainless-app[bot] May 3, 2026
a79813a
codegen metadata
stainless-app[bot] May 3, 2026
8d80f94
codegen metadata
stainless-app[bot] May 3, 2026
b8535df
codegen metadata
stainless-app[bot] May 3, 2026
d47d89a
codegen metadata
stainless-app[bot] May 3, 2026
6b3f278
codegen metadata
stainless-app[bot] May 3, 2026
2a0e1a4
codegen metadata
stainless-app[bot] May 3, 2026
888918e
codegen metadata
stainless-app[bot] May 4, 2026
e3f1889
codegen metadata
stainless-app[bot] May 4, 2026
130d61a
codegen metadata
stainless-app[bot] May 4, 2026
d750e09
codegen metadata
stainless-app[bot] May 4, 2026
168b21f
codegen metadata
stainless-app[bot] May 4, 2026
406192a
codegen metadata
stainless-app[bot] May 4, 2026
082a01a
codegen metadata
stainless-app[bot] May 4, 2026
ad0faaf
codegen metadata
stainless-app[bot] May 4, 2026
7c918db
codegen metadata
stainless-app[bot] May 4, 2026
01492fe
codegen metadata
stainless-app[bot] May 4, 2026
c6f56f7
codegen metadata
stainless-app[bot] May 4, 2026
0d318ad
chore(internal): version bump
stainless-app[bot] May 4, 2026
eda66f5
codegen metadata
stainless-app[bot] May 4, 2026
91db92c
codegen metadata
stainless-app[bot] May 4, 2026
6b4dd25
codegen metadata
stainless-app[bot] May 4, 2026
c1e7675
batch SGP span upserts (#331)
alvinkam2001 May 4, 2026
b09749b
codegen metadata
stainless-app[bot] May 4, 2026
8921cc6
codegen metadata
stainless-app[bot] May 5, 2026
bf74fd7
codegen metadata
stainless-app[bot] May 5, 2026
5aa039f
codegen metadata
stainless-app[bot] May 5, 2026
5a8ec93
codegen metadata
stainless-app[bot] May 5, 2026
65af241
release: 0.11.0
stainless-app[bot] May 5, 2026
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
2 changes: 1 addition & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
".": "0.10.4"
".": "0.11.0"
}
4 changes: 2 additions & 2 deletions .stats.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
configured_endpoints: 45
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/sgp/agentex-sdk-c108a179582f0e0c6d479ea4b3bc6310a83693987073967c2b6203df23718eb2.yml
openapi_spec_hash: 53b8e5866709af71bef94816b8ede38b
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/sgp/agentex-sdk-307ea66bdd28f83ddc0c526365cfe06f4c1bb2fd421d19f6ebb7f687d06f9ee6.yml
openapi_spec_hash: 5bbd18a405a11e8497d38a5a88b98018
config_hash: fb079ef7936611b032568661b8165f19
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# Changelog

## 0.11.0 (2026-05-05)

Full Changelog: [v0.10.4...v0.11.0](https://github.com/scaleapi/scale-agentex-python/compare/v0.10.4...v0.11.0)

### Features

* **openai_agents:** expose real `usage`, `response_id`, plumb `previous_response_id`, opt-in `prompt_cache_key` for stateful responses and prompt caching ([#335](https://github.com/scaleapi/scale-agentex-python/issues/335)) ([ba5d64b](https://github.com/scaleapi/scale-agentex-python/commit/ba5d64be1f959ff1a35b30e647a0a5ead21a8402))


### Chores

* **internal:** reformat pyproject.toml ([76e0299](https://github.com/scaleapi/scale-agentex-python/commit/76e0299a84d283bbaa1b51c1d9c19f507c4858ba))
* **internal:** version bump ([0d318ad](https://github.com/scaleapi/scale-agentex-python/commit/0d318adfcbe6a8b09bef2a81ff70fe5a59acef46))

## 0.10.4 (2026-05-04)

Full Changelog: [v0.10.3...v0.10.4](https://github.com/scaleapi/scale-agentex-python/compare/v0.10.3...v0.10.4)
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "agentex-sdk"
version = "0.10.4"
version = "0.11.0"
description = "The official Python library for the agentex API"
dynamic = ["readme"]
license = "Apache-2.0"
Expand Down
2 changes: 1 addition & 1 deletion src/agentex/_version.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.

__title__ = "agentex"
__version__ = "0.10.4" # x-release-please-version
__version__ = "0.11.0" # x-release-please-version
78 changes: 48 additions & 30 deletions src/agentex/lib/core/tracing/processors/sgp_tracing_processor.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import annotations

from typing import override

import scale_gp_beta.lib.tracing as tracing
Expand Down Expand Up @@ -125,48 +127,64 @@ def _add_source_to_span(self, span: Span) -> None:

@override
async def on_span_start(self, span: Span) -> None:
self._add_source_to_span(span)
sgp_span = create_span(
name=span.name,
span_type=_get_span_type(span),
span_id=span.id,
parent_id=span.parent_id,
trace_id=span.trace_id,
input=span.input,
output=span.output,
metadata=span.data,
)
sgp_span.start_time = span.start_time.isoformat() # type: ignore[union-attr]
await self.on_spans_start([span])

@override
async def on_span_end(self, span: Span) -> None:
await self.on_spans_end([span])

@override
async def on_spans_start(self, spans: list[Span]) -> None:
if not spans:
return

sgp_spans: list[SGPSpan] = []
for span in spans:
self._add_source_to_span(span)
sgp_span = create_span(
name=span.name,
span_type=_get_span_type(span),
span_id=span.id,
parent_id=span.parent_id,
trace_id=span.trace_id,
input=span.input,
output=span.output,
metadata=span.data,
)
sgp_span.start_time = span.start_time.isoformat() # type: ignore[union-attr]
self._spans[span.id] = sgp_span
sgp_spans.append(sgp_span)

if self.disabled:
logger.warning("SGP is disabled, skipping span upsert")
return
# TODO(AGX1-198): Batch multiple spans into a single upsert_batch call
# instead of one span per HTTP request.
# https://linear.app/scale-epd/issue/AGX1-198/actually-use-sgp-batching-for-spans
await self.sgp_async_client.spans.upsert_batch( # type: ignore[union-attr]
items=[sgp_span.to_request_params()]
items=[s.to_request_params() for s in sgp_spans]
)
Comment on lines +141 to 163
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 _spans populated before upsert β€” stale entries on HTTP failure

Spans are added to self._spans before the upsert_batch HTTP call (lines 155–156). If the batch upsert throws (network error, server 5xx), the exception is caught upstream by the queue's _handle, but _spans already holds entries for spans whose start event was never delivered to SGP. A subsequent on_spans_end will find those spans, update them, and send end-only upserts β€” orphaned end events with no matching start on the server.

The old single-span code registered the span in _spans only after a successful upsert, so failures were cleanly skipped on the end path. Consider populating _spans only after confirming the batch call succeeded, or rolling back entries on exception.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agentex/lib/core/tracing/processors/sgp_tracing_processor.py
Line: 141-163

Comment:
**`_spans` populated before upsert β€” stale entries on HTTP failure**

Spans are added to `self._spans` before the `upsert_batch` HTTP call (lines 155–156). If the batch upsert throws (network error, server 5xx), the exception is caught upstream by the queue's `_handle`, but `_spans` already holds entries for spans whose start event was never delivered to SGP. A subsequent `on_spans_end` will find those spans, update them, and send end-only upserts β€” orphaned end events with no matching start on the server.

The old single-span code registered the span in `_spans` only after a successful upsert, so failures were cleanly skipped on the end path. Consider populating `_spans` only after confirming the batch call succeeded, or rolling back entries on exception.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Cursor Fix in Claude Code Fix in Codex

Comment on lines +154 to 163
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 shutdown() crashes with AttributeError when disabled=True and spans are in-flight

on_spans_start now populates self._spans (line 155) before the if self.disabled: return guard (line 158). If any spans are started but not yet ended when shutdown() is called in disabled mode, it reaches self.sgp_async_client.spans.upsert_batch(...) where self.sgp_async_client is None, triggering an AttributeError. Before this PR the disabled path returned before populating _spans, so _spans was always empty at shutdown time and this was never triggered in practice. The fix is to either move the self._spans[span.id] = sgp_span assignment after the if self.disabled guard, or add an early if self.disabled: return check at the top of shutdown() (mirroring how on_spans_end handles it at line 184).

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agentex/lib/core/tracing/processors/sgp_tracing_processor.py
Line: 154-163

Comment:
**`shutdown()` crashes with `AttributeError` when `disabled=True` and spans are in-flight**

`on_spans_start` now populates `self._spans` (line 155) **before** the `if self.disabled: return` guard (line 158). If any spans are started but not yet ended when `shutdown()` is called in disabled mode, it reaches `self.sgp_async_client.spans.upsert_batch(...)` where `self.sgp_async_client` is `None`, triggering an `AttributeError`. Before this PR the disabled path returned before populating `_spans`, so `_spans` was always empty at shutdown time and this was never triggered in practice. The fix is to either move the `self._spans[span.id] = sgp_span` assignment after the `if self.disabled` guard, or add an early `if self.disabled: return` check at the top of `shutdown()` (mirroring how `on_spans_end` handles it at line 184).

How can I resolve this? If you propose a fix, please make it concise.

Fix in Cursor Fix in Claude Code Fix in Codex


self._spans[span.id] = sgp_span

@override
async def on_span_end(self, span: Span) -> None:
sgp_span = self._spans.pop(span.id, None)
if sgp_span is None:
logger.warning(f"Span {span.id} not found in stored spans, skipping span end")
async def on_spans_end(self, spans: list[Span]) -> None:
if not spans:
return

self._add_source_to_span(span)
sgp_span.input = span.input # type: ignore[assignment]
sgp_span.output = span.output # type: ignore[assignment]
sgp_span.metadata = span.data # type: ignore[assignment]
sgp_span.end_time = span.end_time.isoformat() # type: ignore[union-attr]

if self.disabled:
to_upsert: list[SGPSpan] = []
for span in spans:
sgp_span = self._spans.pop(span.id, None)
if sgp_span is None:
logger.warning(f"Span {span.id} not found in stored spans, skipping span end")
continue

self._add_source_to_span(span)
sgp_span.input = span.input # type: ignore[assignment]
sgp_span.output = span.output # type: ignore[assignment]
sgp_span.metadata = span.data # type: ignore[assignment]
sgp_span.end_time = span.end_time.isoformat() # type: ignore[union-attr]
to_upsert.append(sgp_span)

if self.disabled or not to_upsert:
return
await self.sgp_async_client.spans.upsert_batch( # type: ignore[union-attr]
items=[sgp_span.to_request_params()]
items=[s.to_request_params() for s in to_upsert]
)

@override
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
from __future__ import annotations

import asyncio
from abc import ABC, abstractmethod

from agentex.types.span import Span
from agentex.lib.types.tracing import TracingProcessorConfig
from agentex.lib.utils.logging import make_logger

logger = make_logger(__name__)


class SyncTracingProcessor(ABC):
Expand Down Expand Up @@ -35,6 +41,43 @@ async def on_span_start(self, span: Span) -> None:
async def on_span_end(self, span: Span) -> None:
pass

async def on_spans_start(self, spans: list[Span]) -> None:
"""Batched variant of on_span_start.

Default fallback fans out to the single-span method in parallel so
existing processors keep working unchanged. Processors that support
real batching (e.g. sending all spans in one HTTP call) should
override this to avoid the per-span round trip.

Per-span exceptions are captured and logged individually so that one
failing span does not prevent the others from being processed.
"""
results = await asyncio.gather(
*(self.on_span_start(s) for s in spans), return_exceptions=True
)
for span, result in zip(spans, results):
if isinstance(result, Exception):
logger.error(
"Tracing processor %s failed on_span_start for span %s",
type(self).__name__,
span.id,
exc_info=result,
)

async def on_spans_end(self, spans: list[Span]) -> None:
"""Batched variant of on_span_end. See on_spans_start for details."""
results = await asyncio.gather(
*(self.on_span_end(s) for s in spans), return_exceptions=True
)
for span, result in zip(spans, results):
if isinstance(result, Exception):
logger.error(
"Tracing processor %s failed on_span_end for span %s",
type(self).__name__,
span.id,
exc_info=result,
)

@abstractmethod
async def shutdown(self) -> None:
pass
43 changes: 27 additions & 16 deletions src/agentex/lib/core/tracing/span_queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,29 +95,40 @@ async def _drain_loop(self) -> None:

@staticmethod
async def _process_items(items: list[_SpanQueueItem]) -> None:
"""Process a list of span events concurrently."""
"""Dispatch a batch of same-event-type items to each processor in one call.

async def _handle(item: _SpanQueueItem) -> None:
Groups spans by processor so each processor sees its full slice of the
drain batch at once. Processors that override the batched methods can
then send a single HTTP request per drain cycle instead of N.
"""
if not items:
return

event_type = items[0].event_type
assert all(i.event_type == event_type for i in items), (
"_process_items requires all items to share the same event_type; "
"callers must split START and END batches before dispatching."
)
Comment on lines +107 to +111
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 assert in production guard defeats data-corruption protection

The code comment correctly identifies this as a potential "silent data-corruption bug," but using assert for the guard means it is silently stripped when Python runs with the -O (optimize) flag. If a caller ever passes a mixed-event-type list, START and END spans would be fed to the wrong batched method with no warning. Use an explicit if/raise instead.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agentex/lib/core/tracing/span_queue.py
Line: 107-111

Comment:
**`assert` in production guard defeats data-corruption protection**

The code comment correctly identifies this as a potential "silent data-corruption bug," but using `assert` for the guard means it is silently stripped when Python runs with the `-O` (optimize) flag. If a caller ever passes a mixed-event-type list, START and END spans would be fed to the wrong batched method with no warning. Use an explicit `if/raise` instead.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Cursor Fix in Claude Code Fix in Codex

by_processor: dict[AsyncTracingProcessor, list[Span]] = {}
for item in items:
for p in item.processors:
by_processor.setdefault(p, []).append(item.span)

async def _handle(p: AsyncTracingProcessor, spans: list[Span]) -> None:
try:
if item.event_type == SpanEventType.START:
coros = [p.on_span_start(item.span) for p in item.processors]
if event_type == SpanEventType.START:
await p.on_spans_start(spans)
else:
coros = [p.on_span_end(item.span) for p in item.processors]
results = await asyncio.gather(*coros, return_exceptions=True)
for result in results:
if isinstance(result, Exception):
logger.error(
"Tracing processor error during %s for span %s",
item.event_type.value,
item.span.id,
exc_info=result,
)
await p.on_spans_end(spans)
except Exception:
logger.exception(
"Unexpected error in span queue for span %s", item.span.id
"Tracing processor %s failed handling %d spans during %s",
type(p).__name__,
len(spans),
event_type.value,
)

await asyncio.gather(*[_handle(item) for item in items])
await asyncio.gather(*[_handle(p, spans) for p, spans in by_processor.items()])

# ------------------------------------------------------------------
# Shutdown
Expand Down
39 changes: 39 additions & 0 deletions tests/lib/core/tracing/processors/test_sgp_tracing_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,3 +188,42 @@ async def test_sgp_span_input_updated_on_end(self):
assert len(processor._spans) == 0
# The end upsert should have been called
assert processor.sgp_async_client.spans.upsert_batch.call_count == 2 # start + end

async def test_on_spans_start_sends_single_upsert_for_batch(self):
"""Given N spans at once, on_spans_start should make ONE upsert_batch HTTP call."""
processor, _ = self._make_processor()

n = 10
spans = [_make_span() for _ in range(n)]
with patch(f"{MODULE}.create_span", side_effect=lambda **kw: _make_mock_sgp_span()):
await processor.on_spans_start(spans)

assert processor.sgp_async_client.spans.upsert_batch.call_count == 1, (
"Batched on_spans_start must make exactly one upsert_batch HTTP call"
)
items = processor.sgp_async_client.spans.upsert_batch.call_args.kwargs["items"]
assert len(items) == n
# All spans should be tracked for the subsequent end call
assert len(processor._spans) == n

async def test_on_spans_end_sends_single_upsert_for_batch(self):
"""Given N spans at once, on_spans_end should make ONE upsert_batch HTTP call."""
processor, _ = self._make_processor()

n = 10
spans = [_make_span() for _ in range(n)]
with patch(f"{MODULE}.create_span", side_effect=lambda **kw: _make_mock_sgp_span()):
await processor.on_spans_start(spans)

processor.sgp_async_client.spans.upsert_batch.reset_mock()

for span in spans:
span.end_time = datetime.now(UTC)
await processor.on_spans_end(spans)

assert processor.sgp_async_client.spans.upsert_batch.call_count == 1, (
"Batched on_spans_end must make exactly one upsert_batch HTTP call"
)
items = processor.sgp_async_client.spans.upsert_batch.call_args.kwargs["items"]
assert len(items) == n
assert len(processor._spans) == 0
Loading
Loading