Skip to content

Commit 7ea3a39

Browse files
committed
Merge remote-tracking branch 'origin/next' into release-please--branches--main--changes--next
2 parents cab4bd6 + 55a0178 commit 7ea3a39

17 files changed

Lines changed: 772 additions & 10 deletions

File tree

‎.stats.yml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
configured_endpoints: 75
2-
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/sgp/agentex-sdk-ee0c521f0612c31b874bd595b90cd9209545bab603983552a1a7a87f38ed931e.yml
3-
openapi_spec_hash: 917a1ffe9e353bed2740524dec786ed2
2+
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/sgp/agentex-sdk-fbc0683871d6abb03588f30d9468eeeeacb2b8538eb0c9002813e6df68c5802b.yml
3+
openapi_spec_hash: 4ecd8d496f056dccf80826264ddf8fe1
44
config_hash: 593e89b291976a5e84e4c3c3f8324354

‎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/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):

‎src/agentex/lib/cli/utils/cli_utils.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,18 @@
55

66
console = Console()
77

8+
# asyncio's StreamReader defaults to 64 KiB, and a single log line above that makes
9+
# readline() raise. Agents legitimately emit large lines (serialized charts, payloads
10+
# echoed back by validation errors), so give the reader room before it has to drop one.
11+
#
12+
# Lives here rather than beside its users so that both the normal spawns in
13+
# cli/handlers/run_handlers.py and the debug spawns in cli/debug/debug_handlers.py can
14+
# import it: run_handlers imports cli.debug, so the constant cannot live in either one.
15+
# Keep the two in step. A subprocess left on the asyncio default overruns far more
16+
# easily, and enough consecutive overruns exhaust the reader's retry bound and stop it
17+
# draining, which is the deadlock the bound is there to avoid.
18+
SUBPROCESS_STREAM_LIMIT = 8 * 1024 * 1024
19+
820

921
def handle_questionary_cancellation(
1022
result: str | None, operation: str = "operation"

‎src/agentex/lib/core/temporal/workers/worker.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ def __init__(
178178
metrics_headers: dict[str, str] | None = None,
179179
metrics_use_http: bool = False,
180180
metrics_temporality_delta: bool = False,
181+
agent_card: Any | None = None,
181182
):
182183
self.task_queue = task_queue
183184
self.activity_handles = []
@@ -196,6 +197,7 @@ def __init__(
196197
self.metrics_temporality_delta = metrics_temporality_delta
197198
self.payload_codec = payload_codec
198199
self.data_converter = data_converter
200+
self.agent_card = agent_card
199201

200202
@overload
201203
async def run(
@@ -312,6 +314,6 @@ async def _register_agent(self):
312314
# the worker process never goes through the ACP server lifespan, so it needs its
313315
# own guard (mirrors base_acp_server.lifespan_context).
314316
await assert_backend_compatible(env_vars.AGENTEX_BASE_URL)
315-
await register_agent(env_vars)
317+
await register_agent(env_vars, agent_card=self.agent_card)
316318
else:
317319
logger.warning("AGENTEX_BASE_URL not set, skipping worker registration")

‎src/agentex/lib/types/agent_card.py‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from enum import Enum
66
from typing import TYPE_CHECKING, Any, get_args, get_origin
77

8-
from pydantic import BaseModel
8+
from pydantic import Field, BaseModel
99

1010
if TYPE_CHECKING:
1111
from agentex.lib.sdk.state_machine.state import State
@@ -31,6 +31,11 @@ class AgentCard(BaseModel):
3131
data_events: list[str] = []
3232
input_types: list[str] = []
3333
output_schema: dict | None = None
34+
# Free-form JSON object for opt-in self-description (e.g. protocol-specific
35+
# capability flags). Not interpreted by the platform, but callers can filter
36+
# agents on it with ``agents.list(agent_card_metadata=...)`` -- see
37+
# ``agentex.lib.utils.metadata_filters.encode_metadata_filter``.
38+
metadata: dict[str, Any] = Field(default_factory=dict)
3439

3540
@classmethod
3641
def from_states(
@@ -40,6 +45,7 @@ def from_states(
4045
output_event_model: type[BaseModel] | None = None,
4146
extra_input_types: list[str] | None = None,
4247
queries: list[str] | None = None,
48+
metadata: dict[str, Any] | None = None,
4349
) -> AgentCard:
4450
"""Build an AgentCard directly from a list[State] + initial_state.
4551
@@ -81,6 +87,7 @@ def from_states(
8187
data_events=data_events,
8288
input_types=sorted(derived_input_types | set(extra_input_types or [])),
8389
output_schema=output_schema,
90+
metadata=metadata or {},
8491
)
8592

8693
@classmethod
@@ -90,6 +97,7 @@ def from_state_machine(
9097
output_event_model: type[BaseModel] | None = None,
9198
extra_input_types: list[str] | None = None,
9299
queries: list[str] | None = None,
100+
metadata: dict[str, Any] | None = None,
93101
) -> AgentCard:
94102
"""Build an AgentCard from a StateMachine instance. Delegates to from_states()."""
95103
lifecycle = state_machine.get_lifecycle()
@@ -125,6 +133,7 @@ def from_state_machine(
125133
data_events=data_events,
126134
input_types=sorted(derived_input_types | set(extra_input_types or [])),
127135
output_schema=output_schema,
136+
metadata=metadata or {},
128137
)
129138

130139

‎src/agentex/lib/utils/logging.py‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,25 @@
1111

1212
ctx_var_request_id = contextvars.ContextVar[str]("request_id")
1313

14+
DEFAULT_LOG_LEVEL = logging.INFO
15+
16+
17+
def resolve_log_level() -> int:
18+
"""Read the log level from ``LOG_LEVEL``, falling back to INFO.
19+
20+
Read straight from the environment rather than through ``EnvVarKeys``, since
21+
``environment_variables`` imports this module and the reverse would be a cycle.
22+
23+
``getLevelName`` returns the string ``"Level FOO"`` for anything it does not
24+
recognise, so the isinstance check is what stops a typo in ``LOG_LEVEL`` from
25+
silently turning logging off.
26+
"""
27+
configured = os.getenv("LOG_LEVEL")
28+
if not configured:
29+
return DEFAULT_LOG_LEVEL
30+
level = logging.getLevelName(configured.strip().upper())
31+
return level if isinstance(level, int) else DEFAULT_LOG_LEVEL
32+
1433

1534
class CustomJSONFormatter(json_log_formatter.JSONFormatter):
1635
def json_record(self, message: str, extra: dict, record: logging.LogRecord) -> dict: # type: ignore[override]
@@ -51,7 +70,7 @@ def make_logger(name: str) -> logging.Logger:
5170
"""
5271
# Create a console object to print colored text
5372
logger = logging.getLogger(name)
54-
logger.setLevel(logging.INFO)
73+
logger.setLevel(resolve_log_level())
5574

5675
environment = os.getenv("ENVIRONMENT")
5776
if environment == "local":
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""Helpers for the platform's JSON-encoded metadata filter query parameters.
2+
3+
The containment filters on ``agents.list(agent_card_metadata=...)`` and
4+
``tasks.list(task_metadata=...)`` carry their filter as a JSON-encoded object
5+
inside a single query string value, so the generated clients type them as
6+
``str``. Encoding by hand is easy to get subtly wrong -- Python's ``json``
7+
happily emits ``NaN``/``Infinity``, which the server rejects with a 400 -- so
8+
these helpers do it once, here, in the hand-written layer where they survive
9+
SDK regeneration.
10+
11+
from agentex.lib.utils.metadata_filters import encode_metadata_filter
12+
13+
client.agents.list(
14+
agent_card_metadata=encode_metadata_filter({"permits_capable": True}),
15+
)
16+
17+
The ``agent_card_metadata`` filter requires an Agentex server that includes
18+
scaleapi/scale-agentex#411. Older servers ignore the unknown query parameter
19+
and return the full unfiltered agent list rather than erroring, and the SDK's
20+
startup backend-contract check does not guard against this.
21+
"""
22+
23+
from __future__ import annotations
24+
25+
import json
26+
from typing import Any, Mapping
27+
28+
__all__ = ["encode_metadata_filter"]
29+
30+
31+
def encode_metadata_filter(metadata: Mapping[str, Any]) -> str:
32+
"""Encode a metadata filter mapping into the wire form the platform expects.
33+
34+
Args:
35+
metadata: The key/value pairs the target's metadata object must contain.
36+
Values may be any JSON type; matching is exact containment, so
37+
``{"permits_capable": True}`` matches a stored JSON ``true`` but not
38+
the string ``"true"``. An empty mapping matches any target that has
39+
a metadata object at all.
40+
41+
Returns:
42+
A compact JSON object string, with keys sorted so the same filter always
43+
produces the same query value.
44+
45+
Raises:
46+
TypeError: If ``metadata`` is not a mapping, or contains a value that
47+
isn't JSON-serializable.
48+
ValueError: If a value is a non-finite float. ``NaN`` and ``Infinity``
49+
aren't valid JSON and the server rejects them with a 400, so fail
50+
here with a clearer message instead.
51+
"""
52+
if not isinstance(metadata, Mapping):
53+
raise TypeError(f"metadata must be a mapping, got {type(metadata).__name__}")
54+
55+
try:
56+
return json.dumps(metadata, allow_nan=False, separators=(",", ":"), sort_keys=True)
57+
except ValueError as exc:
58+
raise ValueError(f"metadata filter is not encodable as JSON: {exc}") from exc

‎src/agentex/resources/agents/agents.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ def retrieve(
116116
def list(
117117
self,
118118
*,
119+
agent_card_metadata: Optional[str] | Omit = omit,
119120
limit: int | Omit = omit,
120121
order_by: Optional[str] | Omit = omit,
121122
order_direction: str | Omit = omit,
@@ -132,6 +133,13 @@ def list(
132133
List all registered agents, optionally filtered by query parameters.
133134
134135
Args:
136+
agent_card_metadata: JSON-encoded object used to filter agents on
137+
`registration_metadata.agent_card.metadata` via JSONB containment. Example:
138+
{"permits_capable": true}. Only matches cards published through the direct
139+
registration path: registrations that carry a `deployment_id` write the card to
140+
the deployment record instead of `registration_metadata`, so those agents never
141+
match this filter.
142+
135143
limit: Limit
136144
137145
order_by: Field to order by
@@ -159,6 +167,7 @@ def list(
159167
timeout=timeout,
160168
query=maybe_transform(
161169
{
170+
"agent_card_metadata": agent_card_metadata,
162171
"limit": limit,
163172
"order_by": order_by,
164173
"order_direction": order_direction,
@@ -777,6 +786,7 @@ async def retrieve(
777786
async def list(
778787
self,
779788
*,
789+
agent_card_metadata: Optional[str] | Omit = omit,
780790
limit: int | Omit = omit,
781791
order_by: Optional[str] | Omit = omit,
782792
order_direction: str | Omit = omit,
@@ -793,6 +803,13 @@ async def list(
793803
List all registered agents, optionally filtered by query parameters.
794804
795805
Args:
806+
agent_card_metadata: JSON-encoded object used to filter agents on
807+
`registration_metadata.agent_card.metadata` via JSONB containment. Example:
808+
{"permits_capable": true}. Only matches cards published through the direct
809+
registration path: registrations that carry a `deployment_id` write the card to
810+
the deployment record instead of `registration_metadata`, so those agents never
811+
match this filter.
812+
796813
limit: Limit
797814
798815
order_by: Field to order by
@@ -820,6 +837,7 @@ async def list(
820837
timeout=timeout,
821838
query=await async_maybe_transform(
822839
{
840+
"agent_card_metadata": agent_card_metadata,
823841
"limit": limit,
824842
"order_by": order_by,
825843
"order_direction": order_direction,

‎src/agentex/types/agent_list_params.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,16 @@
99

1010

1111
class AgentListParams(TypedDict, total=False):
12+
agent_card_metadata: Optional[str]
13+
"""
14+
JSON-encoded object used to filter agents on
15+
`registration_metadata.agent_card.metadata` via JSONB containment. Example:
16+
{"permits_capable": true}. Only matches cards published through the direct
17+
registration path: registrations that carry a `deployment_id` write the card to
18+
the deployment record instead of `registration_metadata`, so those agents never
19+
match this filter.
20+
"""
21+
1222
limit: int
1323
"""Limit"""
1424

0 commit comments

Comments
 (0)