Skip to content

Commit f17ad00

Browse files
stainless-app[bot]scale-ballendeclan-scalesmoreinis
authored
release: 0.10.3 (#330)
* feat(api): api update * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * chore(internal): more robust bootstrap script * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * fix: use correct field name format for multipart file arrays * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * feat: support setting headers via env * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * codegen metadata * fix: allow litellm security patch (#336) * fix(adk): Always inject headers on execute activity (#337) * perf(streaming): coalesce per-token publishes to Redis (50ms / 128-char window) (#333) * perf(streaming): coalesce per-token publishes to Redis (50ms / 128-char window) Per-token Redis publishes from TemporalStreamingModel were adding ~45s (56-62%) overhead to agent response latency, mostly from head-of-line blocking on the model's event loop: each `await streaming_context.stream_update(...)` inside the OpenAI stream `async for` paused token consumption until the publish round-trip completed. This change introduces a `CoalescingBuffer` driven by an `asyncio.Event`, so the producer never awaits on Redis. Deltas are merged consecutive-only (preserving character order in every (type, index) channel) and flushed on a 50ms timer, on a 128-char size threshold, or immediately for the first delta to keep perceived responsiveness high. The buffer's `close()` drains remaining deltas before the DONE event, so consumers see the full sequence in order. A new `StreamingMode = Literal["off", "per_token", "coalesced"]` lives in `streaming.py` as the single source of truth and is plumbed through the adk streaming module, `StreamingService.streaming_task_message_context`, and `StreamingTaskMessageContext`. Default is `"coalesced"` everywhere, so all 13+ existing context callers (claude_agents, langgraph, litellm provider, openai sync provider, etc.) benefit automatically. * chore(streaming): fix import ordering (ruff I001) * fix(streaming): address greptile review findings - _run: when CancelledError is raised mid-flush in the for-loop, re-enqueue the in-flight item plus any remaining items in the local `drained` list back into self._buf so close()'s final drain can recover them. Previously the local `drained` list was unreachable after CancelledError exited the for-loop, causing the last coalesced batch to be silently dropped on close-during-flush races. Trade-off: the in-flight item may be duplicated on the consumer side (Redis pub may have completed before cancel was delivered), which is preferable to silent loss for streaming UX. - _merge_pair: replace `return b` fallback with AssertionError. All six current TaskMessageDelta variants have explicit isinstance branches, so the fallback is unreachable today. But _can_merge returns True for any same-type pair, so adding a 7th delta variant without updating _merge_pair would silently drop `a`'s accumulated content. Asserting turns a future silent data-loss into an immediate, diagnosable crash. * test(streaming): add coalescing-layer tests; loosen one model assertion After merging the test-suite repair from main (#334) into this branch, one model test (test_responses_api_streaming) regressed because its assert_called_with strict-matched all kwargs of streaming_task_message_context and didn't tolerate the new `streaming_mode='coalesced'` kwarg this PR adds. Switched to assert_called() + targeted kwarg checks so the test verifies what it cares about (task_id threading) without locking in implementation details. Replaced the ad-hoc smoke scripts that lived in conversation with a real pytest module at tests/lib/core/services/adk/test_streaming.py covering: - _delta_char_len, _can_merge, _merge_pair: per-channel correctness + None-handling - _merge_consecutive: pure-text collapse, cross-channel order preservation, per-channel reconstruction matches per-token semantics - CoalescingBuffer: first-delta-immediate flush within ~20ms, size-threshold flush before timer fires, multi-delta coalescing within one window, idle close, add-after-close no-op - CoalescingBuffer cancel-during-flush regression test for the P1 fix: five queued chunks must all surface across publishes when close() cancels mid-flush (asserts substring presence rather than exact ordering, since the documented trade-off allows duplicates of the in-flight item) - StreamingTaskMessageContext mode dispatch: "off" suppresses publishes but persists full content, "per_token" publishes each delta synchronously, "coalesced" batches and persists full content * chore(streaming): route TemporalStreamingModel logger through make_logger The model file used raw ``logging.getLogger("agentex.temporal.streaming")``, which returns a logger with no handler attached and no level configured — so the existing ``[TemporalStreamingModel] Initialized ... streaming_mode=...`` INFO log was silently dropped, making it impossible to verify at runtime that a coalesced (or any) streaming mode was actually wired. Switch to the SDK's ``make_logger`` helper (level=INFO, RichHandler in local mode, StreamHandler otherwise) used everywhere else in the SDK. The explicit logger name ``agentex.temporal.streaming`` is preserved so any external logging configuration targeting that name keeps working. * codegen metadata * feat(api): api update * release: 0.10.3 --------- Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> Co-authored-by: Brandon Allen <brandon.allen@scale.com> Co-authored-by: Declan Brady <declan.brady@scale.com> Co-authored-by: Stas Moreinis <stas.moreinis@scale.com>
1 parent 7e5e69c commit f17ad00

27 files changed

Lines changed: 1487 additions & 354 deletions

File tree

.release-please-manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
{
2-
".": "0.10.2"
2+
".": "0.10.3"
33
}

.stats.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
configured_endpoints: 45
2-
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/sgp%2Fagentex-sdk-eeb5bf63b18d948611eec48d0225e9bba63b170f64eeeb35d91825724b7cf6c3.yml
3-
openapi_spec_hash: 5bbd18a405a11e8497d38a5a88b98018
2+
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/sgp/agentex-sdk-c108a179582f0e0c6d479ea4b3bc6310a83693987073967c2b6203df23718eb2.yml
3+
openapi_spec_hash: 53b8e5866709af71bef94816b8ede38b
44
config_hash: fb079ef7936611b032568661b8165f19

CHANGELOG.md

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

3+
## 0.10.3 (2026-04-30)
4+
5+
Full Changelog: [v0.10.2...v0.10.3](https://github.com/scaleapi/scale-agentex-python/compare/v0.10.2...v0.10.3)
6+
7+
### Features
8+
9+
* **api:** api update ([16ab771](https://github.com/scaleapi/scale-agentex-python/commit/16ab771ab1396b94c768ec5185c2f8ed07eff556))
10+
* **api:** api update ([fe77732](https://github.com/scaleapi/scale-agentex-python/commit/fe77732da48c872739bc6296d2932d4d9c810a35))
11+
* support setting headers via env ([a73fd73](https://github.com/scaleapi/scale-agentex-python/commit/a73fd73ea036fc195c124636337acdc0552f18f1))
12+
13+
14+
### Bug Fixes
15+
16+
* **adk:** Always inject headers on execute activity ([#337](https://github.com/scaleapi/scale-agentex-python/issues/337)) ([9d80e0b](https://github.com/scaleapi/scale-agentex-python/commit/9d80e0b797a9ed7a0838003294dc7a595ab18de5))
17+
* allow litellm security patch ([#336](https://github.com/scaleapi/scale-agentex-python/issues/336)) ([c980948](https://github.com/scaleapi/scale-agentex-python/commit/c9809482d5e6095063115d1851f0b92a5e5a3755))
18+
* **tests:** repair test_streaming_model so all 28 tests run and pass ([#334](https://github.com/scaleapi/scale-agentex-python/issues/334)) ([7e5e69c](https://github.com/scaleapi/scale-agentex-python/commit/7e5e69c132c89d054516e1a762e0437375859663))
19+
* use correct field name format for multipart file arrays ([bd6d362](https://github.com/scaleapi/scale-agentex-python/commit/bd6d362aee81873b7969b0367488029e2bb0314b))
20+
21+
22+
### Performance Improvements
23+
24+
* **streaming:** coalesce per-token publishes to Redis (50ms / 128-char window) ([#333](https://github.com/scaleapi/scale-agentex-python/issues/333)) ([e6f11c4](https://github.com/scaleapi/scale-agentex-python/commit/e6f11c45e6dc3186770088688ad45cc251387e4a))
25+
26+
27+
### Chores
28+
29+
* **internal:** more robust bootstrap script ([f004301](https://github.com/scaleapi/scale-agentex-python/commit/f0043013a44ddcd9f356a8e0a548e4a295cb1b1d))
30+
331
## 0.10.2 (2026-04-21)
432

533
Full Changelog: [v0.10.1...v0.10.2](https://github.com/scaleapi/scale-agentex-python/compare/v0.10.1...v0.10.2)

examples/tutorials/run_agent_test.sh

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,18 @@ run_test() {
260260

261261

262262
# Run the tests with retry mechanism
263+
local -a pytest_cmd=("uv" "run" "pytest")
264+
if [ "$BUILD_CLI" = true ]; then
265+
local wheel_file
266+
wheel_file=$(ls /home/runner/work/*/*/dist/agentex_sdk-*.whl 2>/dev/null | head -n1)
267+
if [[ -z "$wheel_file" ]]; then
268+
wheel_file=$(ls "${SCRIPT_DIR}/../../dist/agentex_sdk-*.whl" 2>/dev/null | head -n1)
269+
fi
270+
if [[ -n "$wheel_file" ]]; then
271+
pytest_cmd=("uv" "run" "--with" "$wheel_file" "pytest")
272+
fi
273+
fi
274+
263275
local max_retries=5
264276
local retry_count=0
265277
local exit_code=1
@@ -270,7 +282,7 @@ run_test() {
270282
fi
271283

272284
# Stream pytest output directly in real-time
273-
uv run pytest tests/test_agent.py -v -s
285+
"${pytest_cmd[@]}" tests/test_agent.py -v -s
274286
exit_code=$?
275287

276288
if [ $exit_code -eq 0 ]; then

pyproject.toml

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "agentex-sdk"
3-
version = "0.10.2"
3+
version = "0.10.3"
44
description = "The official Python library for the agentex API"
55
dynamic = ["readme"]
66
license = "Apache-2.0"
@@ -9,7 +9,7 @@ authors = [
99
]
1010

1111
dependencies = [
12-
"httpx>=0.27.2,<0.28",
12+
"httpx>=0.28.1,<0.29",
1313
"pydantic>=2.0.0, <3",
1414
"typing-extensions>=4.14, <5",
1515
"anyio>=3.5.0, <5",
@@ -18,7 +18,9 @@ dependencies = [
1818
"typer>=0.16,<0.17",
1919
"questionary>=2.0.1,<3",
2020
"rich>=13.9.2,<14",
21-
"fastapi>=0.115.0,<0.116",
21+
"fastapi>=0.115.0",
22+
"starlette>=0.49.1",
23+
"tornado>=6.5.5",
2224
"uvicorn>=0.31.1",
2325
"watchfiles>=0.24.0,<1.0",
2426
"python-on-whales>=0.73.0,<0.74",
@@ -28,7 +30,7 @@ dependencies = [
2830
"temporalio>=1.26.0,<2",
2931
"aiohttp>=3.10.10,<4",
3032
"redis>=5.2.0,<6",
31-
"litellm>=1.83.0,<2",
33+
"litellm>=1.83.7,<2",
3234
"kubernetes>=25.0.0,<36.0.0",
3335
"jinja2>=3.1.3,<4",
3436
"mcp[cli]>=1.4.1",

scripts/bootstrap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ set -e
44

55
cd "$(dirname "$0")/.."
66

7-
if [ -f "Brewfile" ] && [ "$(uname -s)" = "Darwin" ] && [ "$SKIP_BREW" != "1" ] && [ -t 0 ]; then
7+
if [ -f "Brewfile" ] && [ "$(uname -s)" = "Darwin" ] && [ "${SKIP_BREW:-}" != "1" ] && [ -t 0 ]; then
88
brew bundle check >/dev/null 2>&1 || {
99
echo -n "==> Install Homebrew dependencies? (y/N): "
1010
read -r response

src/agentex/_client.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,11 @@
1919
RequestOptions,
2020
not_given,
2121
)
22-
from ._utils import is_given, get_async_library
22+
from ._utils import (
23+
is_given,
24+
is_mapping_t,
25+
get_async_library,
26+
)
2327
from ._compat import cached_property
2428
from ._version import __version__
2529
from ._streaming import Stream as Stream, AsyncStream as AsyncStream
@@ -123,6 +127,15 @@ def __init__(
123127
except KeyError as exc:
124128
raise ValueError(f"Unknown environment: {environment}") from exc
125129

130+
custom_headers_env = os.environ.get("AGENTEX_CUSTOM_HEADERS")
131+
if custom_headers_env is not None:
132+
parsed: dict[str, str] = {}
133+
for line in custom_headers_env.split("\n"):
134+
colon = line.find(":")
135+
if colon >= 0:
136+
parsed[line[:colon].strip()] = line[colon + 1 :].strip()
137+
default_headers = {**parsed, **(default_headers if is_mapping_t(default_headers) else {})}
138+
126139
super().__init__(
127140
version=__version__,
128141
base_url=base_url,
@@ -363,6 +376,15 @@ def __init__(
363376
except KeyError as exc:
364377
raise ValueError(f"Unknown environment: {environment}") from exc
365378

379+
custom_headers_env = os.environ.get("AGENTEX_CUSTOM_HEADERS")
380+
if custom_headers_env is not None:
381+
parsed: dict[str, str] = {}
382+
for line in custom_headers_env.split("\n"):
383+
colon = line.find(":")
384+
if colon >= 0:
385+
parsed[line[:colon].strip()] = line[colon + 1 :].strip()
386+
default_headers = {**parsed, **(default_headers if is_mapping_t(default_headers) else {})}
387+
366388
super().__init__(
367389
version=__version__,
368390
base_url=base_url,

src/agentex/_qs.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,13 @@
22

33
from typing import Any, List, Tuple, Union, Mapping, TypeVar
44
from urllib.parse import parse_qs, urlencode
5-
from typing_extensions import Literal, get_args
5+
from typing_extensions import get_args
66

7-
from ._types import NotGiven, not_given
7+
from ._types import NotGiven, ArrayFormat, NestedFormat, not_given
88
from ._utils import flatten
99

1010
_T = TypeVar("_T")
1111

12-
13-
ArrayFormat = Literal["comma", "repeat", "indices", "brackets"]
14-
NestedFormat = Literal["dots", "brackets"]
15-
1612
PrimitiveData = Union[str, int, float, bool, None]
1713
# this should be Data = Union[PrimitiveData, "List[Data]", "Tuple[Data]", "Mapping[str, Data]"]
1814
# https://github.com/microsoft/pyright/issues/3555

src/agentex/_types.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@
4747
ModelT = TypeVar("ModelT", bound=pydantic.BaseModel)
4848
_T = TypeVar("_T")
4949

50+
ArrayFormat = Literal["comma", "repeat", "indices", "brackets"]
51+
NestedFormat = Literal["dots", "brackets"]
52+
5053

5154
# Approximates httpx internal ProxiesTypes and RequestFiles types
5255
# while adding support for `PathLike` instances

src/agentex/_utils/_utils.py

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,11 @@
1717
)
1818
from pathlib import Path
1919
from datetime import date, datetime
20-
from typing_extensions import TypeGuard
20+
from typing_extensions import TypeGuard, get_args
2121

2222
import sniffio
2323

24-
from .._types import Omit, NotGiven, FileTypes, HeadersLike
24+
from .._types import Omit, NotGiven, FileTypes, ArrayFormat, HeadersLike
2525

2626
_T = TypeVar("_T")
2727
_TupleT = TypeVar("_TupleT", bound=Tuple[object, ...])
@@ -40,25 +40,45 @@ def extract_files(
4040
query: Mapping[str, object],
4141
*,
4242
paths: Sequence[Sequence[str]],
43+
array_format: ArrayFormat = "brackets",
4344
) -> list[tuple[str, FileTypes]]:
4445
"""Recursively extract files from the given dictionary based on specified paths.
4546
4647
A path may look like this ['foo', 'files', '<array>', 'data'].
4748
49+
``array_format`` controls how ``<array>`` segments contribute to the emitted
50+
field name. Supported values: ``"brackets"`` (``foo[]``), ``"repeat"`` and
51+
``"comma"`` (``foo``), ``"indices"`` (``foo[0]``, ``foo[1]``).
52+
4853
Note: this mutates the given dictionary.
4954
"""
5055
files: list[tuple[str, FileTypes]] = []
5156
for path in paths:
52-
files.extend(_extract_items(query, path, index=0, flattened_key=None))
57+
files.extend(_extract_items(query, path, index=0, flattened_key=None, array_format=array_format))
5358
return files
5459

5560

61+
def _array_suffix(array_format: ArrayFormat, array_index: int) -> str:
62+
if array_format == "brackets":
63+
return "[]"
64+
if array_format == "indices":
65+
return f"[{array_index}]"
66+
if array_format == "repeat" or array_format == "comma":
67+
# Both repeat the bare field name for each file part; there is no
68+
# meaningful way to comma-join binary parts.
69+
return ""
70+
raise NotImplementedError(
71+
f"Unknown array_format value: {array_format}, choose from {', '.join(get_args(ArrayFormat))}"
72+
)
73+
74+
5675
def _extract_items(
5776
obj: object,
5877
path: Sequence[str],
5978
*,
6079
index: int,
6180
flattened_key: str | None,
81+
array_format: ArrayFormat,
6282
) -> list[tuple[str, FileTypes]]:
6383
try:
6484
key = path[index]
@@ -75,9 +95,11 @@ def _extract_items(
7595

7696
if is_list(obj):
7797
files: list[tuple[str, FileTypes]] = []
78-
for entry in obj:
79-
assert_is_file_content(entry, key=flattened_key + "[]" if flattened_key else "")
80-
files.append((flattened_key + "[]", cast(FileTypes, entry)))
98+
for array_index, entry in enumerate(obj):
99+
suffix = _array_suffix(array_format, array_index)
100+
emitted_key = (flattened_key + suffix) if flattened_key else suffix
101+
assert_is_file_content(entry, key=emitted_key)
102+
files.append((emitted_key, cast(FileTypes, entry)))
81103
return files
82104

83105
assert_is_file_content(obj, key=flattened_key)
@@ -106,6 +128,7 @@ def _extract_items(
106128
path,
107129
index=index,
108130
flattened_key=flattened_key,
131+
array_format=array_format,
109132
)
110133
elif is_list(obj):
111134
if key != "<array>":
@@ -117,9 +140,12 @@ def _extract_items(
117140
item,
118141
path,
119142
index=index,
120-
flattened_key=flattened_key + "[]" if flattened_key is not None else "[]",
143+
flattened_key=(
144+
(flattened_key if flattened_key is not None else "") + _array_suffix(array_format, array_index)
145+
),
146+
array_format=array_format,
121147
)
122-
for item in obj
148+
for array_index, item in enumerate(obj)
123149
]
124150
)
125151

0 commit comments

Comments
 (0)