diff --git a/python/packages/core/agent_framework/_types.py b/python/packages/core/agent_framework/_types.py index f3ed9ad2d2..5fbdcd1079 100644 --- a/python/packages/core/agent_framework/_types.py +++ b/python/packages/core/agent_framework/_types.py @@ -3,6 +3,7 @@ from __future__ import annotations import base64 +import contextlib import json import logging import re @@ -2890,6 +2891,7 @@ def __init__( self._inner_stream_source: ResponseStream[Any, Any] | Awaitable[ResponseStream[Any, Any]] | None = None self._wrap_inner: bool = False self._map_update: Callable[[Any], UpdateT | Awaitable[UpdateT]] | None = None + self._pull_context_manager_factories: list[Callable[[], contextlib.AbstractContextManager[Any]]] = [] def map( self, @@ -3008,11 +3010,18 @@ def __aiter__(self) -> ResponseStream[UpdateT, FinalT]: return self async def __anext__(self) -> UpdateT: - if self._iterator is None: - stream = await self._get_stream() - self._iterator = stream.__aiter__() try: - update: UpdateT = await self._iterator.__anext__() + with contextlib.ExitStack() as stack: + for factory in self._pull_context_manager_factories: + stack.enter_context(factory()) + # Resolve the underlying stream inside the pull contexts so that any + # spans/contexts created during stream resolution (e.g. inner chat + # completion spans created on the first pull of a wrapped agent stream) + # inherit the active context (e.g. an outer agent invoke span). + if self._iterator is None: + stream = await self._get_stream() + self._iterator = stream.__aiter__() + update: UpdateT = await self._iterator.__anext__() except StopAsyncIteration: self._consumed = True await self._run_cleanup_hooks() @@ -3038,9 +3047,25 @@ async def __anext__(self) -> UpdateT: update = hooked return update + async def _resolve_stream_with_pull_contexts(self) -> AsyncIterable[UpdateT]: + """Resolve the underlying stream while activating any registered pull context managers. + + Used by ``__await__`` and ``get_final_response`` so that any spans/contexts created + during stream resolution (e.g. when the source is an Awaitable that internally + creates child telemetry spans) inherit the same active context as iterator pulls. + ``__anext__`` resolves the stream inside its own ExitStack and so calls ``_get_stream`` + directly. + """ + if self._stream is not None: + return await self._get_stream() + with contextlib.ExitStack() as stack: + for factory in self._pull_context_manager_factories: + stack.enter_context(factory()) + return await self._get_stream() + def __await__(self) -> Any: async def _wrap() -> ResponseStream[UpdateT, FinalT]: - await self._get_stream() + await self._resolve_stream_with_pull_contexts() return self return _wrap().__await__() @@ -3064,10 +3089,12 @@ async def get_final_response(self) -> FinalT: """ if self._wrap_inner: if self._inner_stream is None: - # Use _get_stream() to resolve the awaitable - this properly handles + # Use _resolve_stream_with_pull_contexts() so that any spans/contexts + # created while resolving the awaitable (e.g. inner telemetry spans) + # inherit the same active context as iterator pulls. This also handles # the case where _stream_source and _inner_stream_source are the same # coroutine (e.g., from from_awaitable), avoiding double-await errors. - await self._get_stream() + await self._resolve_stream_with_pull_contexts() if self._inner_stream is None: raise RuntimeError("Inner stream not available") if not self._finalized and not self._consumed: @@ -3177,6 +3204,25 @@ def with_cleanup_hook( self._cleanup_hooks.append(hook) return self + def with_pull_context_manager( + self, + cm_factory: Callable[[], contextlib.AbstractContextManager[Any]], + ) -> ResponseStream[UpdateT, FinalT]: + """Register a context manager factory invoked around each underlying iterator pull. + + The factory is called once per ``__anext__`` and the returned context manager wraps + the await of the underlying iterator. This is useful for state that needs to be + active while the inner async work runs - for example, attaching an OpenTelemetry + span to the current context so child spans created by inner code (HTTP clients, + tool execution) are correctly parented. + + Because the context manager is entered and exited within the same ``__anext__`` + invocation, attach/detach style operations remain symmetric in the same async + context regardless of where the stream is iterated. + """ + self._pull_context_manager_factories.append(cm_factory) + return self + async def _run_cleanup_hooks(self) -> None: if self._cleanup_run: return diff --git a/python/packages/core/agent_framework/observability.py b/python/packages/core/agent_framework/observability.py index 6998e5994f..051319926f 100644 --- a/python/packages/core/agent_framework/observability.py +++ b/python/packages/core/agent_framework/observability.py @@ -26,6 +26,7 @@ from typing import TYPE_CHECKING, Any, ClassVar, Final, Generic, Literal, TypedDict, cast, overload from dotenv import load_dotenv +from opentelemetry import context as otel_context from opentelemetry import metrics, trace from . import __version__ as version_info @@ -1277,27 +1278,8 @@ def get_response( ) if stream: - result_stream = cast( - ResponseStream[ChatResponseUpdate, ChatResponse[Any]], - super_get_response( - messages=messages, - stream=True, - options=opts, - compaction_strategy=compaction_strategy, - tokenizer=tokenizer, - function_invocation_kwargs=function_invocation_kwargs, - client_kwargs=merged_client_kwargs, - ), - ) + span = _start_streaming_span(attributes, OtelAttr.REQUEST_MODEL) - # Create span directly without trace.use_span() context attachment. - # Streaming spans are closed asynchronously in cleanup hooks, which run - # in a different async context than creation — using use_span() would - # cause "Failed to detach context" errors from OpenTelemetry. - operation = attributes.get(OtelAttr.OPERATION, "operation") - span_name = attributes.get(OtelAttr.REQUEST_MODEL, "unknown") - span = get_tracer().start_span(f"{operation} {span_name}") - span.set_attributes(attributes) if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages: _capture_messages( span=span, @@ -1319,6 +1301,24 @@ def _close_span() -> None: def _record_duration() -> None: duration_state["duration"] = perf_counter() - start_time + try: + result_stream = cast( + ResponseStream[ChatResponseUpdate, ChatResponse[Any]], + super_get_response( + messages=messages, + stream=True, + options=opts, + compaction_strategy=compaction_strategy, + tokenizer=tokenizer, + function_invocation_kwargs=function_invocation_kwargs, + client_kwargs=merged_client_kwargs, + ), + ) + except Exception as exception: + capture_exception(span=span, exception=exception, timestamp=time_ns()) + _close_span() + raise + async def _finalize_stream() -> None: from ._types import ChatResponse @@ -1357,11 +1357,18 @@ async def _finalize_stream() -> None: finally: _close_span() - # Register a weak reference callback to close the span if stream is garbage collected - # without being consumed. This ensures spans don't leak if users don't consume streams. - wrapped_stream: ResponseStream[ChatResponseUpdate, ChatResponse[Any]] = result_stream.with_cleanup_hook( - _record_duration - ).with_cleanup_hook(_finalize_stream) + # The pull context manager attaches the span around each underlying iterator pull so + # that child spans created during the pull (e.g. HTTP requests, inner tool execution) + # are parented under this chat span. Attach and detach happen in the same async + # context as the pull, avoiding cross-context cleanup issues. The weakref finalizer + # ensures the span is closed even if the stream is garbage collected without being + # consumed. + wrapped_stream: ResponseStream[ChatResponseUpdate, ChatResponse[Any]] = ( + result_stream + .with_cleanup_hook(_record_duration) + .with_cleanup_hook(_finalize_stream) + .with_pull_context_manager(lambda: _activate_span(span)) + ) weakref.finalize(wrapped_stream, _close_span) return wrapped_stream @@ -1543,23 +1550,8 @@ def _trace_agent_invocation( inner_accumulated_usage_token = INNER_ACCUMULATED_USAGE.set({}) if stream: - try: - run_result: object = execute() - if isinstance(run_result, ResponseStream): - result_stream: ResponseStream[AgentResponseUpdate, AgentResponse[Any]] = run_result # pyright: ignore[reportUnknownVariableType] - elif isinstance(run_result, Awaitable): - result_stream = ResponseStream.from_awaitable(run_result) # type: ignore[arg-type] # pyright: ignore[reportArgumentType] - else: - raise RuntimeError("Streaming telemetry requires a ResponseStream result.") - except Exception: - INNER_RESPONSE_TELEMETRY_CAPTURED_FIELDS.reset(inner_response_telemetry_captured_fields_token) - INNER_ACCUMULATED_USAGE.reset(inner_accumulated_usage_token) - raise + span = _start_streaming_span(attributes, OtelAttr.AGENT_NAME) - operation = attributes.get(OtelAttr.OPERATION, "operation") - span_name = attributes.get(OtelAttr.AGENT_NAME, "unknown") - span = get_tracer().start_span(f"{operation} {span_name}") - span.set_attributes(attributes) if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages: _capture_messages( span=span, @@ -1581,6 +1573,21 @@ def _close_span() -> None: def _record_duration() -> None: duration_state["duration"] = perf_counter() - start_time + try: + run_result: object = execute() + if isinstance(run_result, ResponseStream): + result_stream: ResponseStream[AgentResponseUpdate, AgentResponse[Any]] = run_result # pyright: ignore[reportUnknownVariableType] + elif isinstance(run_result, Awaitable): + result_stream = ResponseStream.from_awaitable(run_result) # type: ignore[arg-type] # pyright: ignore[reportArgumentType] + else: + raise RuntimeError("Streaming telemetry requires a ResponseStream result.") + except Exception as exception: + capture_exception(span=span, exception=exception, timestamp=time_ns()) + INNER_RESPONSE_TELEMETRY_CAPTURED_FIELDS.reset(inner_response_telemetry_captured_fields_token) + INNER_ACCUMULATED_USAGE.reset(inner_accumulated_usage_token) + _close_span() + raise + async def _finalize_stream() -> None: from ._types import AgentResponse @@ -1620,9 +1627,18 @@ async def _finalize_stream() -> None: INNER_ACCUMULATED_USAGE.reset(inner_accumulated_usage_token) _close_span() - wrapped_stream: ResponseStream[AgentResponseUpdate, AgentResponse[Any]] = result_stream.with_cleanup_hook( - _record_duration - ).with_cleanup_hook(_finalize_stream) + # The pull context manager attaches the span around each underlying iterator pull so + # that child spans created during the pull (e.g. inner chat completion spans from the + # underlying ChatTelemetryLayer) are parented under this agent invoke span. Attach and + # detach happen in the same async context as the pull, avoiding cross-context cleanup + # issues. The weakref finalizer ensures the span is closed even if the stream is + # garbage collected without being consumed. + wrapped_stream: ResponseStream[AgentResponseUpdate, AgentResponse[Any]] = ( + result_stream + .with_cleanup_hook(_record_duration) + .with_cleanup_hook(_finalize_stream) + .with_pull_context_manager(lambda: _activate_span(span)) + ) weakref.finalize(wrapped_stream, _close_span) return wrapped_stream @@ -1809,6 +1825,27 @@ def get_function_span( ) +@contextlib.contextmanager +def _activate_span(span: trace.Span) -> Generator[None]: + """Attach ``span`` as the current span in the OpenTelemetry context. + + Designed to be used as a per-pull context manager registered on a + ``ResponseStream`` via ``with_pull_context_manager``: it attaches the span + before each underlying iterator pull and detaches immediately after, so + child spans created during the pull (HTTP clients, inner chat completions, + tool execution) are correctly parented under ``span``. + + Because attach and detach happen within the same ``__anext__`` invocation + (and therefore the same async task / contextvars context), there is no risk + of "Failed to detach context" warnings from cross-context cleanup. + """ + token = otel_context.attach(trace.set_span_in_context(span)) + try: + yield + finally: + otel_context.detach(token) + + @contextlib.contextmanager def _get_span( attributes: dict[str, Any], @@ -1831,6 +1868,29 @@ def _get_span( yield current_span +def _start_streaming_span(attributes: dict[str, Any], span_name_attribute: str) -> trace.Span: + """Start a non-current span for a streaming operation. + + Unlike :func:`_get_span`, the returned span is not attached to the current + OpenTelemetry context. The caller is responsible for: + + - Ending the span via cleanup hooks on the wrapped + :class:`~agent_framework._types.ResponseStream`. + - Activating the span around each iterator pull via + :func:`_activate_span` registered with ``with_pull_context_manager`` so + that child spans created during stream production inherit it as parent. + + Streaming spans are closed asynchronously in cleanup hooks that run in a + different async context than creation, so attaching the span at creation + time would cause "Failed to detach context" errors from OpenTelemetry. + """ + operation = attributes.get(OtelAttr.OPERATION, "operation") + span_name = attributes.get(span_name_attribute, "unknown") + span = get_tracer().start_span(f"{operation} {span_name}") + span.set_attributes(attributes) + return span + + def _get_instructions_from_options(options: Any) -> str | list[str] | None: """Extract instructions from options dict.""" if options is None: diff --git a/python/packages/core/tests/core/test_observability.py b/python/packages/core/tests/core/test_observability.py index 85091a71df..71b59a351b 100644 --- a/python/packages/core/tests/core/test_observability.py +++ b/python/packages/core/tests/core/test_observability.py @@ -3313,3 +3313,487 @@ class _InstrumentedAgent(AgentTelemetryLayer, RawAgent): # The invoke_agent span must aggregate usage from the in-loop call and the final exhaustion call assert agent_span.attributes.get(OtelAttr.INPUT_TOKENS) == 500 assert agent_span.attributes.get(OtelAttr.OUTPUT_TOKENS) == 100 + + +# region Test span nesting (parent-child relationships) + + +@pytest.mark.parametrize("stream", [False, True]) +async def test_chat_span_nested_under_agent_span(span_exporter: InMemorySpanExporter, stream: bool): + """The inner chat span must be a child of the outer agent invoke span.""" + + class NestedChatClient(ChatTelemetryLayer, BaseChatClient[Any]): + def service_url(self): + return "https://test.example.com" + + def _inner_get_response( + self, *, messages: MutableSequence[Message], stream: bool, options: dict[str, Any], **kwargs: Any + ) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]: + if stream: + + async def _stream() -> AsyncIterable[ChatResponseUpdate]: + yield ChatResponseUpdate(contents=[Content.from_text("Hello")], role="assistant") + yield ChatResponseUpdate( + contents=[Content.from_text(" world")], role="assistant", finish_reason="stop" + ) + + def _finalize(updates: Sequence[ChatResponseUpdate]) -> ChatResponse: + return ChatResponse( + messages=[Message(role="assistant", contents=["Hello world"])], + response_id="resp_1", + usage_details=UsageDetails(input_token_count=3, output_token_count=4), + finish_reason="stop", + ) + + return ResponseStream(_stream(), finalizer=_finalize) + + async def _get() -> ChatResponse: + return ChatResponse( + messages=[Message(role="assistant", contents=["Hello world"])], + response_id="resp_1", + usage_details=UsageDetails(input_token_count=3, output_token_count=4), + finish_reason="stop", + ) + + return _get() + + agent = Agent( + client=NestedChatClient(), + id="nested_agent_id", + name="nested_agent", + default_options={"model": "NestedModel"}, + ) + + span_exporter.clear() + if stream: + result_stream = agent.run("Test message", stream=True) + async for _ in result_stream: + pass + await result_stream.get_final_response() + else: + await agent.run("Test message") + + spans = span_exporter.get_finished_spans() + assert len(spans) == 2 + + span_by_op = {s.attributes[OtelAttr.OPERATION.value]: s for s in spans} + agent_span = span_by_op[OtelAttr.AGENT_INVOKE_OPERATION] + chat_span = span_by_op[OtelAttr.CHAT_COMPLETION_OPERATION] + + # Agent span has no parent (it is the root) + assert agent_span.parent is None + + # Chat span's parent must be the agent span + assert chat_span.parent is not None + assert chat_span.parent.span_id == agent_span.context.span_id + assert chat_span.parent.trace_id == agent_span.context.trace_id + + # Both spans must share the same trace + assert chat_span.context.trace_id == agent_span.context.trace_id + + +@pytest.mark.parametrize("stream", [False, True]) +async def test_function_call_spans_nested_under_agent_span(span_exporter: InMemorySpanExporter, stream: bool): + """All inner spans (chat completions and execute_tool) must be children of the agent span.""" + from agent_framework import Content + from agent_framework._tools import FunctionInvocationLayer + + @tool(name="get_weather", description="Get the weather for a location") + def get_weather(location: str) -> str: + return f"The weather in {location} is sunny." + + class NestedToolChatClient(FunctionInvocationLayer, ChatTelemetryLayer, BaseChatClient[Any]): + def __init__(self) -> None: + super().__init__() + self.call_count = 0 + + def service_url(self): + return "https://test.example.com" + + def _inner_get_response( + self, *, messages: MutableSequence[Message], stream: bool, options: dict[str, Any], **kwargs: Any + ) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]: + self.call_count += 1 + is_first = self.call_count == 1 + + if stream: + + async def _stream() -> AsyncIterable[ChatResponseUpdate]: + if is_first: + yield ChatResponseUpdate( + contents=[ + Content.from_function_call( + call_id="call_123", + name="get_weather", + arguments='{"location": "Seattle"}', + ) + ], + role="assistant", + ) + else: + yield ChatResponseUpdate( + contents=[Content.from_text("The weather in Seattle is sunny!")], + role="assistant", + finish_reason="stop", + ) + + def _finalize(updates: Sequence[ChatResponseUpdate]) -> ChatResponse: + return ChatResponse.from_updates(updates) + + return ResponseStream(_stream(), finalizer=_finalize) + + async def _get() -> ChatResponse: + if is_first: + return ChatResponse( + messages=[ + Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="call_123", + name="get_weather", + arguments='{"location": "Seattle"}', + ) + ], + ) + ], + ) + return ChatResponse( + messages=[Message(role="assistant", contents=["The weather in Seattle is sunny!"])], + finish_reason="stop", + ) + + return _get() + + agent = Agent( + client=NestedToolChatClient(), + id="tool_agent_id", + name="tool_agent", + default_options={"model": "ToolModel", "tools": [get_weather], "tool_choice": "auto"}, + ) + + span_exporter.clear() + if stream: + result_stream = agent.run("What's the weather in Seattle?", stream=True) + async for _ in result_stream: + pass + await result_stream.get_final_response() + else: + await agent.run("What's the weather in Seattle?") + + spans = span_exporter.get_finished_spans() + + invoke_spans = [s for s in spans if s.attributes.get(OtelAttr.OPERATION.value) == OtelAttr.AGENT_INVOKE_OPERATION] + chat_spans = [s for s in spans if s.attributes.get(OtelAttr.OPERATION.value) == OtelAttr.CHAT_COMPLETION_OPERATION] + tool_spans = [s for s in spans if s.attributes.get(OtelAttr.OPERATION.value) == OtelAttr.TOOL_EXECUTION_OPERATION] + + assert len(invoke_spans) == 1, f"Expected 1 invoke_agent span, got {len(invoke_spans)}" + assert len(chat_spans) == 2, f"Expected 2 chat spans, got {len(chat_spans)}" + assert len(tool_spans) == 1, f"Expected 1 execute_tool span, got {len(tool_spans)}" + + agent_span = invoke_spans[0] + assert agent_span.parent is None + + # All inner spans must be parented under the agent invoke span + for inner in (*chat_spans, *tool_spans): + assert inner.parent is not None, f"Span {inner.name} has no parent" + assert inner.parent.span_id == agent_span.context.span_id, ( + f"Span {inner.name} parent={inner.parent.span_id} != agent={agent_span.context.span_id}" + ) + assert inner.context.trace_id == agent_span.context.trace_id + + +@pytest.mark.parametrize("stream", [False, True]) +async def test_chat_span_nested_under_explicit_outer_span( + span_exporter: InMemorySpanExporter, mock_chat_client, stream: bool +): + """Chat telemetry spans (including streaming) must inherit a user-provided outer span as parent.""" + from agent_framework.observability import get_tracer + + client = mock_chat_client() + span_exporter.clear() + + tracer = get_tracer() + with tracer.start_as_current_span("outer") as outer_span: + outer_ctx = outer_span.get_span_context() + if stream: + stream_obj = client.get_response( + stream=True, messages=[Message(role="user", contents=["Test"])], options={"model": "Test"} + ) + async for _ in stream_obj: + pass + await stream_obj.get_final_response() + else: + await client.get_response(messages=[Message(role="user", contents=["Test"])], options={"model": "Test"}) + + spans = span_exporter.get_finished_spans() + chat_spans = [s for s in spans if s.attributes.get(OtelAttr.OPERATION.value) == OtelAttr.CHAT_COMPLETION_OPERATION] + assert len(chat_spans) == 1 + chat_span = chat_spans[0] + + assert chat_span.parent is not None + assert chat_span.parent.span_id == outer_ctx.span_id + assert chat_span.context.trace_id == outer_ctx.trace_id + + +@pytest.mark.parametrize("stream", [False, True]) +async def test_http_span_nested_under_chat_span(span_exporter: InMemorySpanExporter, stream: bool): + """A span created inside ``_inner_get_response`` (e.g. an HTTP client call to the LLM provider) + must be parented under the chat completion span. + + This validates that the chat span context is active while the inner client implementation + runs, both for non-streaming responses and while streaming updates are being pulled. + """ + from agent_framework.observability import get_tracer + + tracer = get_tracer() + + class HttpEmittingClient(ChatTelemetryLayer, BaseChatClient[Any]): + def service_url(self): + return "https://test.example.com" + + def _inner_get_response( + self, *, messages: MutableSequence[Message], stream: bool, options: dict[str, Any], **kwargs: Any + ) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]: + if stream: + + async def _stream() -> AsyncIterable[ChatResponseUpdate]: + # Simulate an HTTP request to the model provider while producing the stream. + with tracer.start_as_current_span("HTTP POST"): + pass + yield ChatResponseUpdate(contents=[Content.from_text("hi")], role="assistant", finish_reason="stop") + + def _finalize(updates: Sequence[ChatResponseUpdate]) -> ChatResponse: + return ChatResponse.from_updates(updates) + + return ResponseStream(_stream(), finalizer=_finalize) + + async def _get() -> ChatResponse: + # Simulate an HTTP request to the model provider during the call. + with tracer.start_as_current_span("HTTP POST"): + pass + return ChatResponse( + messages=[Message(role="assistant", contents=["done"])], + usage_details=UsageDetails(input_token_count=1, output_token_count=1), + ) + + return _get() + + span_exporter.clear() + client = HttpEmittingClient() + if stream: + result_stream = client.get_response( + stream=True, messages=[Message(role="user", contents=["Test"])], options={"model": "Test"} + ) + async for _ in result_stream: + pass + await result_stream.get_final_response() + else: + await client.get_response(messages=[Message(role="user", contents=["Test"])], options={"model": "Test"}) + + spans = span_exporter.get_finished_spans() + chat_spans = [s for s in spans if s.attributes.get(OtelAttr.OPERATION.value) == OtelAttr.CHAT_COMPLETION_OPERATION] + http_spans = [s for s in spans if s.name == "HTTP POST"] + assert len(chat_spans) == 1 + assert len(http_spans) == 1 + + chat_span = chat_spans[0] + http_span = http_spans[0] + + assert http_span.parent is not None + assert http_span.parent.span_id == chat_span.context.span_id + assert http_span.context.trace_id == chat_span.context.trace_id + + +# region Test ResponseStream.with_pull_context_manager + + +async def test_with_pull_context_manager_enters_and_exits_per_pull(): + """The registered factory is entered and exited symmetrically around each iterator pull.""" + import contextlib + + events: list[str] = [] + + @contextlib.contextmanager + def cm(): + events.append("enter") + try: + yield + finally: + events.append("exit") + + async def src() -> AsyncIterable[int]: + yield 1 + yield 2 + + stream: ResponseStream[int, list[int]] = ResponseStream(src(), finalizer=lambda updates: list(updates)) + stream.with_pull_context_manager(cm) + + pulled = [u async for u in stream] + + assert pulled == [1, 2] + # Enter/exit must be balanced and there must be at least one pair per yielded update. + assert events.count("enter") == events.count("exit") + assert events.count("enter") >= 2 + # Verify symmetric ordering (no overlapping pairs). + for i in range(0, len(events), 2): + assert events[i] == "enter" + assert events[i + 1] == "exit" + + +async def test_with_pull_context_manager_exits_on_iteration_error(): + """The pull context is exited even when the underlying stream raises mid-iteration.""" + import contextlib + + events: list[str] = [] + + @contextlib.contextmanager + def cm(): + events.append("enter") + try: + yield + finally: + events.append("exit") + + async def src() -> AsyncIterable[int]: + yield 1 + raise RuntimeError("boom") + + stream: ResponseStream[int, list[int]] = ResponseStream(src(), finalizer=lambda updates: list(updates)) + stream.with_pull_context_manager(cm) + + with pytest.raises(RuntimeError, match="boom"): + async for _ in stream: + pass + + # Enter/exit balanced even on the failing pull. + assert events.count("enter") == events.count("exit") + assert events.count("enter") >= 2 + + +async def test_with_pull_context_manager_wraps_stream_resolution_via_await(): + """Awaiting a ``from_awaitable`` stream resolves the inner stream under the pull contexts.""" + import contextlib + + events: list[str] = [] + + @contextlib.contextmanager + def cm(): + events.append("enter") + try: + yield + finally: + events.append("exit") + + async def inner() -> AsyncIterable[int]: + yield 1 + + async def make_stream() -> ResponseStream[int, list[int]]: + # Record that we resolve while a pull context is active. + events.append("resolving") + return ResponseStream(inner(), finalizer=lambda updates: list(updates)) + + stream: ResponseStream[int, list[int]] = ResponseStream.from_awaitable(make_stream()) + stream.with_pull_context_manager(cm) + + await stream # Triggers _resolve_stream_with_pull_contexts via __await__ + + assert "resolving" in events + resolve_index = events.index("resolving") + assert events[resolve_index - 1] == "enter" # Pull context active during resolution + + +# region Test streaming telemetry error paths + + +@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True) +async def test_chat_streaming_super_failure_closes_span(span_exporter: InMemorySpanExporter, enable_sensitive_data): + """If the underlying client raises synchronously when constructing the stream, the chat + span is ended and the exception is recorded (no span leak).""" + + class FailingClient(ChatTelemetryLayer, BaseChatClient[Any]): + def service_url(self): + return "https://test.example.com" + + def _inner_get_response( + self, *, messages: MutableSequence[Message], stream: bool, options: dict[str, Any], **kwargs: Any + ) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]: + raise RuntimeError("inner failed") + + span_exporter.clear() + client = FailingClient() + with pytest.raises(RuntimeError, match="inner failed"): + client.get_response(stream=True, messages=[Message(role="user", contents=["Test"])], options={"model": "Test"}) + + spans = span_exporter.get_finished_spans() + chat_spans = [s for s in spans if s.attributes.get(OtelAttr.OPERATION.value) == OtelAttr.CHAT_COMPLETION_OPERATION] + assert len(chat_spans) == 1 + assert chat_spans[0].status.status_code == StatusCode.ERROR + + +@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True) +async def test_agent_streaming_execute_failure_closes_span_and_resets_contextvars( + span_exporter: InMemorySpanExporter, enable_sensitive_data +): + """If ``execute()`` raises synchronously during streaming agent invocation, the agent span is + ended, the exception is recorded, and the telemetry contextvars are reset.""" + from agent_framework.observability import ( + INNER_ACCUMULATED_USAGE, + INNER_RESPONSE_TELEMETRY_CAPTURED_FIELDS, + ) + + class _FailingExecuteAgent: + AGENT_PROVIDER_NAME = "test_provider" + + def __init__(self): + self._id = "failing_execute" + self._name = "Failing Execute" + self._description = "Agent whose stream call raises synchronously" + self._default_options: dict[str, Any] = {} + + @property + def id(self): + return self._id + + @property + def name(self): + return self._name + + @property + def description(self): + return self._description + + @property + def default_options(self): + return self._default_options + + def run(self, messages=None, *, stream: bool = False, session=None, **kwargs): + if stream: + raise RuntimeError("execute failed") + raise NotImplementedError + + class FailingExecuteAgent(AgentTelemetryLayer, _FailingExecuteAgent): + pass + + # Sentinel values to detect that contextvars were reset to their pre-call state. + sentinel_fields: set[str] = set() + sentinel_usage: dict[str, Any] = {} + fields_token = INNER_RESPONSE_TELEMETRY_CAPTURED_FIELDS.set(sentinel_fields) + usage_token = INNER_ACCUMULATED_USAGE.set(sentinel_usage) + try: + agent = FailingExecuteAgent() + span_exporter.clear() + with pytest.raises(RuntimeError, match="execute failed"): + agent.run(messages="Hello", stream=True) + + # Contextvars must be back to the sentinel values registered before the call. + assert INNER_RESPONSE_TELEMETRY_CAPTURED_FIELDS.get() is sentinel_fields + assert INNER_ACCUMULATED_USAGE.get() is sentinel_usage + finally: + INNER_ACCUMULATED_USAGE.reset(usage_token) + INNER_RESPONSE_TELEMETRY_CAPTURED_FIELDS.reset(fields_token) + + spans = span_exporter.get_finished_spans() + agent_spans = [s for s in spans if s.attributes.get(OtelAttr.OPERATION.value) == OtelAttr.AGENT_INVOKE_OPERATION] + assert len(agent_spans) == 1 + assert agent_spans[0].status.status_code == StatusCode.ERROR diff --git a/python/uv.lock b/python/uv.lock index a8ee0ab7f5..ae04b0632e 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'darwin'", @@ -565,7 +565,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, - { name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = "<=0.2.1,>=0.2.1" }, + { name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = ">=0.2.1,<=0.2.1" }, ] [[package]] @@ -2558,6 +2558,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/3f/9859f655d11901e7b2996c6e3d33e0caa9a1d4572c3bc61ed0faa64b2f4c/greenlet-3.3.2-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:9bc885b89709d901859cf95179ec9f6bb67a3d2bb1f0e88456461bd4b7f8fd0d", size = 277747, upload-time = "2026-02-20T20:16:21.325Z" }, { url = "https://files.pythonhosted.org/packages/fb/07/cb284a8b5c6498dbd7cba35d31380bb123d7dceaa7907f606c8ff5993cbf/greenlet-3.3.2-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b568183cf65b94919be4438dc28416b234b678c608cafac8874dfeeb2a9bbe13", size = 579202, upload-time = "2026-02-20T20:47:28.955Z" }, { url = "https://files.pythonhosted.org/packages/ed/45/67922992b3a152f726163b19f890a85129a992f39607a2a53155de3448b8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:527fec58dc9f90efd594b9b700662ed3fb2493c2122067ac9c740d98080a620e", size = 590620, upload-time = "2026-02-20T20:55:55.581Z" }, + { url = "https://files.pythonhosted.org/packages/03/5f/6e2a7d80c353587751ef3d44bb947f0565ec008a2e0927821c007e96d3a7/greenlet-3.3.2-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:508c7f01f1791fbc8e011bd508f6794cb95397fdb198a46cb6635eb5b78d85a7", size = 602132, upload-time = "2026-02-20T21:02:43.261Z" }, { url = "https://files.pythonhosted.org/packages/ad/55/9f1ebb5a825215fadcc0f7d5073f6e79e3007e3282b14b22d6aba7ca6cb8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ad0c8917dd42a819fe77e6bdfcb84e3379c0de956469301d9fd36427a1ca501f", size = 591729, upload-time = "2026-02-20T20:20:58.395Z" }, { url = "https://files.pythonhosted.org/packages/24/b4/21f5455773d37f94b866eb3cf5caed88d6cea6dd2c6e1f9c34f463cba3ec/greenlet-3.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:97245cc10e5515dbc8c3104b2928f7f02b6813002770cfaffaf9a6e0fc2b94ef", size = 1551946, upload-time = "2026-02-20T20:49:31.102Z" }, { url = "https://files.pythonhosted.org/packages/00/68/91f061a926abead128fe1a87f0b453ccf07368666bd59ffa46016627a930/greenlet-3.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8c1fdd7d1b309ff0da81d60a9688a8bd044ac4e18b250320a96fc68d31c209ca", size = 1618494, upload-time = "2026-02-20T20:21:06.541Z" }, @@ -2565,6 +2566,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f3/47/16400cb42d18d7a6bb46f0626852c1718612e35dcb0dffa16bbaffdf5dd2/greenlet-3.3.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:c56692189a7d1c7606cb794be0a8381470d95c57ce5be03fb3d0ef57c7853b86", size = 278890, upload-time = "2026-02-20T20:19:39.263Z" }, { url = "https://files.pythonhosted.org/packages/a3/90/42762b77a5b6aa96cd8c0e80612663d39211e8ae8a6cd47c7f1249a66262/greenlet-3.3.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ebd458fa8285960f382841da585e02201b53a5ec2bac6b156fc623b5ce4499f", size = 581120, upload-time = "2026-02-20T20:47:30.161Z" }, { url = "https://files.pythonhosted.org/packages/bf/6f/f3d64f4fa0a9c7b5c5b3c810ff1df614540d5aa7d519261b53fba55d4df9/greenlet-3.3.2-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a443358b33c4ec7b05b79a7c8b466f5d275025e750298be7340f8fc63dff2a55", size = 594363, upload-time = "2026-02-20T20:55:56.965Z" }, + { url = "https://files.pythonhosted.org/packages/9c/8b/1430a04657735a3f23116c2e0d5eb10220928846e4537a938a41b350bed6/greenlet-3.3.2-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4375a58e49522698d3e70cc0b801c19433021b5c37686f7ce9c65b0d5c8677d2", size = 605046, upload-time = "2026-02-20T21:02:45.234Z" }, { url = "https://files.pythonhosted.org/packages/72/83/3e06a52aca8128bdd4dcd67e932b809e76a96ab8c232a8b025b2850264c5/greenlet-3.3.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e2cd90d413acbf5e77ae41e5d3c9b3ac1d011a756d7284d7f3f2b806bbd6358", size = 594156, upload-time = "2026-02-20T20:20:59.955Z" }, { url = "https://files.pythonhosted.org/packages/70/79/0de5e62b873e08fe3cef7dbe84e5c4bc0e8ed0c7ff131bccb8405cd107c8/greenlet-3.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:442b6057453c8cb29b4fb36a2ac689382fc71112273726e2423f7f17dc73bf99", size = 1554649, upload-time = "2026-02-20T20:49:32.293Z" }, { url = "https://files.pythonhosted.org/packages/5a/00/32d30dee8389dc36d42170a9c66217757289e2afb0de59a3565260f38373/greenlet-3.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45abe8eb6339518180d5a7fa47fa01945414d7cca5ecb745346fc6a87d2750be", size = 1619472, upload-time = "2026-02-20T20:21:07.966Z" }, @@ -2573,6 +2575,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" }, { url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" }, { url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" }, { url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" }, { url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" }, { url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" }, @@ -2581,6 +2584,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" }, { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" }, { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" }, + { url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" }, { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" }, { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" }, { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" }, @@ -2589,6 +2593,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" }, { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, @@ -2597,6 +2602,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, + { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" }, { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" },