Skip to content

Commit 73ea73e

Browse files
aringuyen3claude
andcommitted
fix(stlc): restore the custom-code tree a stale seal anchor reverted
A build on 2026-09-21 (567abff "Build SDK") removed 5,224 lines across 105 files from this trunk -- 14 source files and 10 test files deleted outright, including lib/utils/metadata_filters.py, lib/core/observability/sgp_obs_setup.py, lib/core/temporal/logging.py and lib/core/adapters/llm/_genai_metrics.py. 95 of the 96 changed paths were under the hand-written src/agentex/lib/ and tests/ trees. An stlc seal replays the content diff between its `base` and `integrated` anchors, so anything the trunk gained past `integrated` is overwritten rather than merely skipped. The tracking file in effect named an `integrated` commit dated six days earlier that lived on a side branch, not on this trunk -- and the back-sync that had just brought the custom code here was not an ancestor of it. The replay therefore restored a tree predating the custom code entirely, and every command reported success. This restores the affected paths from the production trunk, which kept all 24 files and is the authoritative copy. Verified: the diff against production for src/agentex/lib/, tests/, adk/README.md and adk/pyproject.toml is now empty. Deliberately NOT restored, because this trunk is correct and production is stale or the file does not apply: src/agentex/_client.py production still defaults to localhost; this trunk carries https://agentex.sgp.scale.com, which is what stainless.yml configures .stats.yml the SaaS-only spec/config hashes are gone by design under self-hosted codegen .github/** this trunk's own CI work release-please-config.json Nothing shipped from the damage: both agentex-sdk-v0.28.1 and agentex-client-v0.28.1 resolve to the production trunk with the custom tree intact. It surfaced only because the promote gate refused to carry the deletion into production. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 86d29c7 commit 73ea73e

97 files changed

Lines changed: 5131 additions & 118 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎adk/README.md‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,26 @@ This automatically pulls in [`agentex-client`](../) (the slim Stainless-generate
2727

2828
The two packages contribute disjoint files to the `agentex.*` namespace — `agentex/lib/*` ships only from `agentex-sdk`.
2929

30+
## Workflow logging
31+
32+
Use the workflow logger in Temporal workflow code:
33+
34+
```python
35+
from agentex.lib.core.temporal.logging import make_workflow_logger
36+
37+
logger = make_workflow_logger(__name__)
38+
```
39+
40+
It suppresses logs while Temporal replays recorded history and adds top-level
41+
`workflow_id` and `run_id` fields during workflow execution. It preserves the
42+
message, caller fields, and exception details. Outside workflows, including in
43+
activities, it behaves like the ordinary SDK logger.
44+
45+
New Temporal templates use this helper. Existing agents must replace their own
46+
workflow loggers to get the same behavior. This does not create trace context or
47+
add trace IDs to workflows that lack it. Temporal's worker diagnostics still report
48+
replay failures.
49+
3050
## Repo layout
3151

3252
This package is hand-authored and lives at `adk/` inside [scaleapi/scale-agentex-python](https://github.com/scaleapi/scale-agentex-python). Stainless codegen never touches `adk/**` — it's outside the generated surface. The sibling `agentex-client` package lives at the repo root and IS Stainless-generated.

‎adk/pyproject.toml‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ dependencies = [
6565
# agentex/lib/* uses `from typing import override` (3.12+) in 19 files.
6666
# The slim agentex-client keeps 3.11 support.
6767
requires-python = ">= 3.12,<4"
68+
6869
classifiers = [
6970
"Typing :: Typed",
7071
"Intended Audience :: Developers",
@@ -76,6 +77,23 @@ classifiers = [
7677
"License :: OSI Approved :: Apache Software License",
7778
]
7879

80+
# No `obs` extra, deliberately — do not add one for sgp-obs.
81+
#
82+
# sgp-obs is not on public PyPI (it is served from Scale's curated CodeArtifact
83+
# mirror), and declaring it in [project.optional-dependencies] makes THIS repo's uv
84+
# workspace unresolvable: `uv sync` re-locks, locking must resolve every declared
85+
# optional dependency of every workspace member, and there is no way to exempt one.
86+
# Measured: `uv lock --check`, `uv sync --all-extras`, plain `uv sync` with no extras,
87+
# and `uv sync --all-extras --no-extra obs` all fail (`--no-extra` filters what is
88+
# installed, not what is resolved); `uv lock` has no `--no-extra`; and
89+
# `[tool.uv] override-dependencies` does not exempt it either. Only `--frozen` works,
90+
# which would leave nobody able to re-lock this repo again.
91+
#
92+
# So the dependency is the AGENT's to declare — `sgp-obs[genai-auto,http,otlp]`
93+
# against the mirror — and the SDK wires it when it is importable. See
94+
# agentex/lib/core/observability/sgp_obs_setup.py; nothing imports sgp_obs outside a
95+
# try, so a plain `pip install agentex-sdk` is unaffected either way.
96+
7997
[project.urls]
8098
Homepage = "https://github.com/scaleapi/scale-agentex-python"
8199
Repository = "https://github.com/scaleapi/scale-agentex-python"

‎src/agentex/lib/adk/utils/_modules/client.py‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import os
12
from typing import override
23

34
import httpx
@@ -26,7 +27,50 @@ def auth_flow(self, request):
2627
yield request
2728

2829

30+
# HTTP timeouts for the AgentEx client, in seconds. Defaults match the SDK's
31+
# DEFAULT_TIMEOUT, so leaving these unset changes nothing.
32+
_TIMEOUT_ENV_DEFAULTS = {
33+
"connect": ("AGENTEX_CLIENT_CONNECT_TIMEOUT_SECONDS", 5.0),
34+
"read": ("AGENTEX_CLIENT_READ_TIMEOUT_SECONDS", 300.0),
35+
"write": ("AGENTEX_CLIENT_WRITE_TIMEOUT_SECONDS", 300.0),
36+
"pool": ("AGENTEX_CLIENT_POOL_TIMEOUT_SECONDS", 300.0),
37+
}
38+
39+
40+
def _timeout_from_env() -> httpx.Timeout:
41+
"""Build the client timeout from environment variables.
42+
43+
Read from ``os.environ`` rather than from ``EnvironmentVariables``. That model
44+
is loaded by worker startup and by ``EnvAuth.auth_flow`` on every request, and
45+
``agentex.lib.adk.utils`` builds a client at import time, so a field added
46+
there would make a malformed timeout break all three. Reading here keeps the
47+
blast radius to the one value that is actually wrong.
48+
49+
The connect timeout is the one worth raising: an AgentEx backend accepts
50+
connections serially, so connect latency grows with the number of callers and
51+
the 5s default is reached when a few hundred are in flight.
52+
"""
53+
values = {}
54+
for field, (env_var, default) in _TIMEOUT_ENV_DEFAULTS.items():
55+
raw = os.environ.get(env_var)
56+
if raw is None or raw.strip() == "":
57+
values[field] = default
58+
continue
59+
try:
60+
values[field] = float(raw)
61+
except ValueError as exc:
62+
raise ValueError(f"{env_var} must be a number in seconds, got {raw!r}") from exc
63+
return httpx.Timeout(**values)
64+
65+
2966
def create_async_agentex_client(**kwargs) -> AsyncAgentex:
67+
"""Create an AsyncAgentex client.
68+
69+
An explicit ``timeout=`` always wins; otherwise the timeout comes from the
70+
AGENTEX_CLIENT_*_TIMEOUT_SECONDS environment variables.
71+
"""
72+
if "timeout" not in kwargs:
73+
kwargs["timeout"] = _timeout_from_env()
3074
client = AsyncAgentex(**kwargs)
3175
client._client.auth = EnvAuth()
3276
return client

‎src/agentex/lib/cli/debug/debug_handlers.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
pass
1717

1818
from agentex.lib.utils.logging import make_logger
19+
from agentex.lib.cli.utils.cli_utils import SUBPROCESS_STREAM_LIMIT
1920

2021
from .debug_config import DebugConfig, resolve_debug_port
2122

@@ -66,6 +67,7 @@ async def start_temporal_worker_debug(
6667
env=debug_env,
6768
stdout=asyncio.subprocess.PIPE,
6869
stderr=asyncio.subprocess.STDOUT,
70+
limit=SUBPROCESS_STREAM_LIMIT,
6971
)
7072

7173

@@ -119,6 +121,7 @@ async def start_acp_server_debug(
119121
env=debug_env,
120122
stdout=asyncio.subprocess.PIPE,
121123
stderr=asyncio.subprocess.STDOUT,
124+
limit=SUBPROCESS_STREAM_LIMIT,
122125
)
123126

124127

‎src/agentex/lib/cli/handlers/deploy_handlers.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,8 @@ def merge_deployment_configs(
389389
_deep_merge(helm_values, agent_env_config.helm_overrides)
390390
logger.info(f"After-merge helm values: {helm_values}")
391391

392+
_stamp_agent_version(helm_values, set(all_env_vars) | {var["name"] for var in secret_env_vars})
393+
392394
# Set final environment variables
393395
# Environment variable precedence: manifest -> environments.yaml -> secrets (highest)
394396
if all_env_vars:
@@ -430,6 +432,14 @@ def _deep_merge(base_dict: dict[str, Any], override_dict: dict[str, Any]) -> Non
430432
base_dict[key] = value
431433

432434

435+
def _stamp_agent_version(helm_values: dict[str, Any], declared_env_names: set[str]) -> None:
436+
"""Set global.agent.version from the merged image tag unless the deployment declares AGENT_VERSION itself."""
437+
if EnvVarKeys.AGENT_VERSION.value in declared_env_names:
438+
# Chart >=0.6.0 renders global.agent.version as a second AGENT_VERSION env entry.
439+
return
440+
helm_values["global"]["agent"].setdefault("version", helm_values["global"]["image"]["tag"])
441+
442+
433443
def create_helm_values_file(helm_values: dict[str, Any]) -> str:
434444
"""Create a temporary helm values file"""
435445
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:

‎src/agentex/lib/cli/handlers/run_handlers.py‎

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from agentex.lib.cli.debug import DebugConfig, start_acp_server_debug, start_temporal_worker_debug
1313
from agentex.lib.utils.logging import make_logger
1414
from agentex.config.agent_manifest import AgentManifest
15+
from agentex.lib.cli.utils.cli_utils import SUBPROCESS_STREAM_LIMIT
1516
from agentex.lib.cli.utils.path_utils import (
1617
get_file_paths,
1718
calculate_uvicorn_target_for_local,
@@ -23,6 +24,11 @@
2324
logger = make_logger(__name__)
2425
console = Console()
2526

27+
# How many consecutive unreadable lines to skip before giving up on the stream.
28+
# Skipping is only known-safe for the limit-overrun case; this bounds the damage
29+
# if some other error repeats without consuming anything.
30+
MAX_CONSECUTIVE_READ_ERRORS = 100
31+
2632

2733
class RunError(Exception):
2834
"""An error occurred during agent run"""
@@ -215,6 +221,7 @@ async def start_acp_server(
215221
env=env,
216222
stdout=asyncio.subprocess.PIPE,
217223
stderr=asyncio.subprocess.STDOUT,
224+
limit=SUBPROCESS_STREAM_LIMIT,
218225
)
219226

220227

@@ -234,23 +241,68 @@ async def start_temporal_worker(
234241
env=env,
235242
stdout=asyncio.subprocess.PIPE,
236243
stderr=asyncio.subprocess.STDOUT,
244+
limit=SUBPROCESS_STREAM_LIMIT,
237245
)
238246

239247

240248
async def stream_process_output(process: asyncio.subprocess.Process, prefix: str):
241-
"""Stream process output with prefix"""
249+
"""Stream process output with prefix.
250+
251+
This loop is the only reader of the child's stdout pipe. If it ever stops
252+
reading, the pipe fills and the child blocks forever inside ``write()``,
253+
which presents as a silent freeze: 0% CPU, no further logs, no traceback.
254+
So a single unreadable line must never end the loop.
255+
"""
242256
try:
243257
if process.stdout is None:
244258
return
259+
consecutive_read_errors = 0
245260
while True:
246-
line = await process.stdout.readline()
261+
try:
262+
line = await process.stdout.readline()
263+
except ValueError as e:
264+
# readline() raises ValueError when a line exceeds the stream limit.
265+
# In *that* case it has already discarded the line and resumed the
266+
# transport, so skipping it makes guaranteed progress. Any other
267+
# ValueError carries no such guarantee, and retrying it forever would
268+
# spin without draining. We cannot tell the two apart (readline
269+
# flattens LimitOverrunError into a bare ValueError), so bound the
270+
# retries and let the outer handler report the hang risk.
271+
consecutive_read_errors += 1
272+
if consecutive_read_errors > MAX_CONSECUTIVE_READ_ERRORS:
273+
raise
274+
logger.warning(
275+
f"Skipping an unreadable line from {prefix}: {e!r} "
276+
f"(consecutive failure {consecutive_read_errors}/{MAX_CONSECUTIVE_READ_ERRORS}). "
277+
f"If this says the chunk exceeded the limit, raise limit= on this "
278+
f"process's create_subprocess_exec."
279+
)
280+
continue
281+
282+
consecutive_read_errors = 0
283+
247284
if not line:
248285
break
249-
decoded_line = line.decode("utf-8").rstrip()
286+
287+
try:
288+
decoded_line = line.decode("utf-8").rstrip()
289+
except UnicodeDecodeError as e:
290+
logger.warning(f"Dropped an undecodable log line from {prefix} ({e}).")
291+
continue
292+
250293
if decoded_line: # Only print non-empty lines
251294
console.print(f"[dim]{prefix}:[/dim] {decoded_line}")
252295
except Exception as e:
253-
logger.debug(f"Output streaming ended for {prefix}: {e}")
296+
# The escalation path, including for the re-raise above. Anything reaching
297+
# here ends the loop, so the child is now at risk of blocking on a full pipe.
298+
# Warning rather than debug: this used to be a debug() that make_logger could
299+
# never emit, which is why three freezes produced no clue.
300+
# CancelledError derives from BaseException, so the auto-reload path that
301+
# cancels these tasks passes straight through and is unaffected.
302+
logger.warning(
303+
f"Output streaming for {prefix} stopped on {e!r}. "
304+
f"Nothing is draining its stdout now, so {prefix} will hang once the pipe fills."
305+
)
254306

255307

256308
async def run_agent(manifest_path: str, debug_config: "DebugConfig | None" = None):
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# The private package index in scaffold Dockerfiles
2+
3+
Every scaffold Dockerfile mounts a build secret named `codeartifact-pip-conf`. It lets an agent
4+
install Scale-internal packages — `sgp-obs`, for instance — that are not on public PyPI, without the
5+
build holding any registry credential of its own. The control-plane broker mints a short-lived
6+
CodeArtifact token per build and injects it as that secret.
7+
8+
- Design: [Private Package Access for Customer Agents (PRD)](https://app.notion.com/p/Private-Package-Access-for-Customer-Agents-PRD-3ad904d6e6cb802cb091df1c25e230bc)
9+
- Tracking: [SGPINF-1568](https://linear.app/scale-epd/issue/SGPINF-1568/provide-scale-internal-packages-to-agentex-agents-in-customer)
10+
11+
## It is inert by default
12+
13+
The mount is `required=false` and guarded by `[ -s ... ]`, so with no secret injected the build is
14+
byte-identical to one without any of this. That covers every local build, every CI build, and every
15+
agent that never opts in. An empty secret file is skipped too.
16+
17+
## Opting in
18+
19+
Add the index to the agent's `pyproject.toml`:
20+
21+
```toml
22+
[[tool.uv.index]]
23+
name = "scale-pypi"
24+
url = "<the scale-customer-pypi URL>"
25+
```
26+
27+
**No `default = true`, deliberately.** An earlier revision of this snippet had it, which was
28+
misleading in both directions. It would not survive the build — the Dockerfiles export
29+
`UV_INDEX`, which binds the mirror as a *named* index ahead of public PyPI rather than
30+
replacing it as the default, and a name rebound that way does not carry the project entry's
31+
default flag. And it is not the behaviour we want anyway: the mirror exists to supply the
32+
Scale-internal packages that are not on public PyPI, not to become the sole source for every
33+
dependency.
34+
35+
So resolution is **mirror first, public PyPI as fallback**. `sgp-obs` can only come from the
36+
mirror, because it exists nowhere else. An ordinary dependency the mirror happens not to carry
37+
still resolves from PyPI instead of failing the build, which is what keeps a scaffolded agent
38+
building when the mirror is incomplete or unreachable.
39+
40+
The name must be exactly `scale-pypi`. uv applies `UV_INDEX_SCALE_PYPI_USERNAME` /
41+
`UV_INDEX_SCALE_PYPI_PASSWORD` to the index of that name, so renaming it makes the credentials
42+
silently stop applying. Setting `UV_INDEX_URL` instead does not authenticate a *named* index at
43+
all, and the resolve fails with a 401.
44+
45+
## Three things that are easy to get wrong
46+
47+
**The token arrives percent-encoded.** The buildspec URL-encodes it to embed it in the pip config's
48+
URL userinfo, so a token containing `+`, `/` or `=` arrives as `%2B`, `%2F`, `%3D`. The `uv sync`
49+
templates decode it before exporting it as a password. Passing it through still-encoded sends a
50+
different string and the resolve 401s.
51+
52+
**The credential must not follow project-controlled configuration.** uv binds credentials by index
53+
*name*, and the name-to-URL mapping would otherwise come from the agent's own `pyproject.toml` — so a
54+
project that pointed `scale-pypi` at another host would receive the token. Verified against a local
55+
server: the rogue host receives `Authorization: Basic aws:<token>` and the real index is never
56+
contacted. The templates therefore export `UV_INDEX` to re-bind the name to the URL the *broker*
57+
supplied, which overrides whatever the project declared. With that in place the rogue host is never
58+
contacted. The pinned URL carries no userinfo; the token still travels only in
59+
`UV_INDEX_SCALE_PYPI_PASSWORD`.
60+
61+
The case this defends is not a malicious agent author — they also write the Dockerfile and could read
62+
the mounted secret directly. It is a *contributed* change to a project file, where a one-line URL edit
63+
is far less conspicuous in review than an exfiltration command in a Dockerfile.
64+
65+
**The two template variants work differently, deliberately.**
66+
67+
| Template | Install step | How the credential is supplied |
68+
| --- | --- | --- |
69+
| `Dockerfile-uv.j2` | `uv sync` against the agent's `pyproject.toml` | Named index `scale-pypi`, pinned via `UV_INDEX`, token decoded into `UV_INDEX_SCALE_PYPI_PASSWORD` |
70+
| `Dockerfile.j2` | `uv pip install -r requirements.txt` | No pyproject is present, so there is no named index to bind to. The credentialed URL is used directly via `UV_DEFAULT_INDEX` |
71+
72+
The `requirements.txt` variant does **not** decode the token, and that is the point: it stays inside
73+
the URL, already encoded for exactly that use. Decoding it there would corrupt it. It is also not
74+
exposed to the redirection problem above, because the URL comes wholly from the injected secret.

‎src/agentex/lib/cli/templates/default-claude-code/Dockerfile-uv.j2‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,34 @@ WORKDIR /app/{{ project_path_from_build_root }}
3434
COPY {{ project_path_from_build_root }}/pyproject.toml ./
3535

3636
# Install dependencies (without project itself, for layer caching)
37+
# Optional private index for Scale-internal packages such as sgp-obs, injected by the
38+
# control-plane broker (SGPINF-1568). Inert unless the secret is present, so local
39+
# builds, CI builds, and agents that never opt in are unaffected.
40+
#
41+
# To opt in, and for why UV_INDEX is pinned to the broker's URL rather than trusting
42+
# the project's, see PRIVATE_INDEX.md in the agentex-sdk CLI templates directory.
3743
RUN --mount=type=cache,target=/root/.cache/uv \
44+
--mount=type=secret,id=codeartifact-pip-conf,required=false \
45+
if [ -s /run/secrets/codeartifact-pip-conf ]; then \
46+
export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \
47+
export UV_INDEX_SCALE_PYPI_USERNAME=aws; \
48+
export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \
49+
| python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \
50+
fi; \
3851
uv sync --no-install-project --no-dev
3952

4053
# Copy the project code
4154
COPY {{ project_path_from_build_root }}/project ./project
4255

4356
# Install the project
4457
RUN --mount=type=cache,target=/root/.cache/uv \
58+
--mount=type=secret,id=codeartifact-pip-conf,required=false \
59+
if [ -s /run/secrets/codeartifact-pip-conf ]; then \
60+
export UV_INDEX="scale-pypi=$(sed -n 's#.*index-url = https://aws:[^@]*@\(.*\)#https://\1#p' /run/secrets/codeartifact-pip-conf | head -1)"; \
61+
export UV_INDEX_SCALE_PYPI_USERNAME=aws; \
62+
export UV_INDEX_SCALE_PYPI_PASSWORD="$(sed -n 's#.*index-url = https://aws:\([^@]*\)@.*#\1#p' /run/secrets/codeartifact-pip-conf \
63+
| python3 -c 'import sys,urllib.parse;print(urllib.parse.unquote(sys.stdin.read().strip()))')"; \
64+
fi; \
4565
uv sync --no-dev
4666

4767
ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH"

‎src/agentex/lib/cli/templates/default-claude-code/Dockerfile.j2‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,19 @@ COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_fr
3333

3434
WORKDIR /app/{{ project_path_from_build_root }}
3535

36+
# Optional private index for Scale-internal packages such as sgp-obs, injected by the
37+
# control-plane broker (SGPINF-1568). Inert unless the secret is present.
38+
#
39+
# This variant installs from requirements.txt, so there is no pyproject.toml for uv to
40+
# read a named index out of; the credentialed URL is used directly and is deliberately
41+
# NOT decoded. See PRIVATE_INDEX.md in the agentex-sdk CLI templates directory.
42+
#
3643
# Install the required Python packages
37-
RUN uv pip install --system -r requirements.txt
44+
RUN --mount=type=secret,id=codeartifact-pip-conf,required=false \
45+
if [ -s /run/secrets/codeartifact-pip-conf ]; then \
46+
export UV_DEFAULT_INDEX="$(sed -n 's#^[[:space:]]*index-url[[:space:]]*=[[:space:]]*##p' /run/secrets/codeartifact-pip-conf | head -1)"; \
47+
fi; \
48+
uv pip install --system -r requirements.txt
3849

3950
# Copy the project code
4051
COPY {{ project_path_from_build_root }}/project /app/{{ project_path_from_build_root }}/project

0 commit comments

Comments
 (0)