Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
".": "0.22.2",
"adk": "0.22.2"
".": "0.23.0",
"adk": "0.23.0"
}
4 changes: 2 additions & 2 deletions .stats.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
configured_endpoints: 75
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/sgp/agentex-sdk-644a4ec06aa1f055c614cbef3379684819a4edd84eeb20d2fb29ae01663622a3.yml
openapi_spec_hash: a6a4dc0c09691ac9783bf38e9653a464
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/sgp/agentex-sdk-330ce4f0d8feed6caeb73d6b12277cfd89f6ad85535b8c8a6f509743b0b6f8cb.yml
openapi_spec_hash: ed6b33682c511df6de538714c0864aa3
config_hash: 593e89b291976a5e84e4c3c3f8324354
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,21 @@

* **tracing:** emit OTel metrics for async span queue depth, batch drain, and SGP export success/failure (HTTP status labels). Disable SDK-side recording with ``AGENTEX_TRACING_METRICS=0``.

## 0.23.0 (2026-08-07)

Full Changelog: [agentex-client-v0.22.2...agentex-client-v0.23.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-client-v0.22.2...agentex-client-v0.23.0)

### Features

* propagate error categories to SGP spans ([#486](https://github.com/scaleapi/scale-agentex-python/issues/486)) ([f2b1808](https://github.com/scaleapi/scale-agentex-python/commit/f2b18087c5049f7e2a159f8e1ad3bf069b01eb23))
* **tracing:** correlate business spans with obs via dedicated wrapper span ([#484](https://github.com/scaleapi/scale-agentex-python/issues/484)) ([72732b7](https://github.com/scaleapi/scale-agentex-python/commit/72732b7c07700df840a2308424154f11a30e39f2))
* **tracing:** propagate OTel trace context across Temporal boundaries ([#485](https://github.com/scaleapi/scale-agentex-python/issues/485)) ([da7ea15](https://github.com/scaleapi/scale-agentex-python/commit/da7ea1558683da05a3f9ecb119b91bf873437be1))


### Bug Fixes

* **task-create:** handle WorkflowAlreadyStartedError gracefully ([#489](https://github.com/scaleapi/scale-agentex-python/issues/489)) ([462195d](https://github.com/scaleapi/scale-agentex-python/commit/462195d11c48713f1b15487adad45776043a09fd))

## 0.22.2 (2026-07-30)

Full Changelog: [agentex-client-v0.22.1...agentex-client-v0.22.2](https://github.com/scaleapi/scale-agentex-python/compare/agentex-client-v0.22.1...agentex-client-v0.22.2)
Expand Down
8 changes: 8 additions & 0 deletions adk/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## 0.23.0 (2026-08-07)

Full Changelog: [agentex-sdk-v0.22.2...agentex-sdk-v0.23.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-sdk-v0.22.2...agentex-sdk-v0.23.0)

### Features

* propagate error categories to SGP spans ([#486](https://github.com/scaleapi/scale-agentex-python/issues/486)) ([f2b1808](https://github.com/scaleapi/scale-agentex-python/commit/f2b18087c5049f7e2a159f8e1ad3bf069b01eb23))

## 0.22.2 (2026-07-30)

Full Changelog: [agentex-sdk-v0.22.1...agentex-sdk-v0.22.2](https://github.com/scaleapi/scale-agentex-python/compare/agentex-sdk-v0.22.1...agentex-sdk-v0.22.2)
Expand Down
4 changes: 2 additions & 2 deletions adk/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
# (agentex/{__init__.py, _*.py, types/, resources/}) ships from the slim
# sibling package `agentex-client` which is pinned as a runtime dep.
name = "agentex-sdk"
version = "0.22.2"
version = "0.23.0"
description = "Agent Development Kit (ADK) overlay for the Agentex API — FastACP server, Temporal workflows, LLM provider integrations, observability"
license = "Apache-2.0"
authors = [
Expand Down Expand Up @@ -53,7 +53,7 @@ dependencies = [
"pydantic-ai-slim>=1.0,<2",
"langgraph-checkpoint>=2.0.0",
"scale-gp>=0.1.0a59",
"scale-gp-beta>=0.2.0",
"scale-gp-beta>=0.5.0",
"mcp>=1.4.1",
# Observability
"ddtrace>=3.13.0",
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
# overlay (formerly `src/agentex/lib/*`) now lives in `adk/` and ships
# as the sibling `agentex-sdk` package — see `adk/pyproject.toml`.
name = "agentex-client"
version = "0.22.2"
version = "0.23.0"
description = "The official Python REST client for the Agentex API"
dynamic = ["readme"]
license = "Apache-2.0"
Expand Down
2 changes: 1 addition & 1 deletion src/agentex/_version.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.

__title__ = "agentex"
__version__ = "0.22.2" # x-release-please-version
__version__ = "0.23.0" # x-release-please-version
19 changes: 19 additions & 0 deletions src/agentex/lib/adk/_modules/tracing.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
StartSpanParams,
TracingActivityName,
)
from agentex.lib.core.tracing.span_error import set_span_error
from agentex.lib.core.tracing.tracer import AsyncTracer
from agentex.lib.core.harness.types import TurnUsage
from agentex.types.span import Span
Expand Down Expand Up @@ -236,6 +237,24 @@ async def span(
)
try:
yield span
except Exception as exc:
# Record the failure on the span so the obs span reflects the error
# instead of a false green. Agents use THIS context manager (not
# AsyncTrace.span, which is the only other place set_span_error is
# called), so without this a failed step closes green. end_span (in
# finally) reads it via get_span_error and propagates it to
# close_obs_span. Stored on span.data, so it round-trips through the
# END_SPAN activity on the Temporal path too.
#
# Guard set_span_error itself: it's obs work and must never replace
# the app's exception on the way out. We always re-raise the ORIGINAL
# exc regardless.
if span:
try:
set_span_error(span, exc)
except Exception: # pragma: no cover - obs must not break app path
pass
raise
finally:
if span:
await self.end_span(
Expand Down
24 changes: 23 additions & 1 deletion src/agentex/lib/core/clients/temporal/temporal_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@
from collections.abc import Callable

from temporalio.client import Client, WorkflowExecutionStatus
from temporalio.common import RetryPolicy as TemporalRetryPolicy, WorkflowIDReusePolicy
from temporalio.common import (
RetryPolicy as TemporalRetryPolicy,
WorkflowIDReusePolicy,
WorkflowIDConflictPolicy,
)
from temporalio.service import RPCError, RPCStatusCode
from temporalio.converter import PayloadCodec, DataConverter

Expand All @@ -15,6 +19,7 @@
TaskStatus,
RetryPolicy,
WorkflowState,
ConflictWorkflowPolicy,
DuplicateWorkflowPolicy,
)
from agentex.lib.core.clients.temporal.utils import get_temporal_client
Expand Down Expand Up @@ -75,6 +80,13 @@
DuplicateWorkflowPolicy.TERMINATE_IF_RUNNING: WorkflowIDReusePolicy.TERMINATE_IF_RUNNING,
}

CONFLICT_POLICY_TO_ID_CONFLICT_POLICY = {
ConflictWorkflowPolicy.UNSPECIFIED: WorkflowIDConflictPolicy.UNSPECIFIED,
ConflictWorkflowPolicy.FAIL: WorkflowIDConflictPolicy.FAIL,
ConflictWorkflowPolicy.USE_EXISTING: WorkflowIDConflictPolicy.USE_EXISTING,
ConflictWorkflowPolicy.TERMINATE_EXISTING: WorkflowIDConflictPolicy.TERMINATE_EXISTING,
}


class TemporalClient:
def __init__(
Expand Down Expand Up @@ -151,18 +163,28 @@ async def start_workflow(
self,
*args: Any,
duplicate_policy: DuplicateWorkflowPolicy = DuplicateWorkflowPolicy.ALLOW_DUPLICATE,
conflict_policy: ConflictWorkflowPolicy = ConflictWorkflowPolicy.UNSPECIFIED,
retry_policy: RetryPolicy = DEFAULT_RETRY_POLICY,
task_timeout: timedelta = timedelta(seconds=10),
execution_timeout: timedelta | None = None,
**kwargs: Any,
) -> str:
if (
duplicate_policy == DuplicateWorkflowPolicy.TERMINATE_IF_RUNNING
and conflict_policy != ConflictWorkflowPolicy.UNSPECIFIED
):
raise ValueError(
"conflict_policy cannot be set when duplicate_policy is TERMINATE_IF_RUNNING; "
"use ConflictWorkflowPolicy.TERMINATE_EXISTING instead"
)
temporal_retry_policy = TemporalRetryPolicy(**retry_policy.model_dump(exclude_unset=True))
workflow_handle = await self.client.start_workflow(
*args,
retry_policy=temporal_retry_policy,
task_timeout=task_timeout,
execution_timeout=execution_timeout,
id_reuse_policy=DUPLICATE_POLICY_TO_ID_REUSE_POLICY[duplicate_policy],
id_conflict_policy=CONFLICT_POLICY_TO_ID_CONFLICT_POLICY[conflict_policy],
**kwargs,
)
return workflow_handle.id
Expand Down
7 changes: 7 additions & 0 deletions src/agentex/lib/core/clients/temporal/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ class DuplicateWorkflowPolicy(str, Enum):
TERMINATE_IF_RUNNING = "TERMINATE_IF_RUNNING"


class ConflictWorkflowPolicy(str, Enum):
UNSPECIFIED = "UNSPECIFIED"
FAIL = "FAIL"
USE_EXISTING = "USE_EXISTING"
TERMINATE_EXISTING = "TERMINATE_EXISTING"


class TaskStatus(str, Enum):
CANCELED = "CANCELED"
COMPLETED = "COMPLETED"
Expand Down
5 changes: 5 additions & 0 deletions src/agentex/lib/core/clients/temporal/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
from temporalio.converter import PayloadCodec, DataConverter
from temporalio.contrib.pydantic import pydantic_data_converter

from agentex.lib.core.tracing.temporal import temporal_tracing_interceptors

# class DateTimeJSONEncoder(AdvancedJSONEncoder):
# def default(self, o: Any) -> Any:
# if isinstance(o, datetime.datetime):
Expand Down Expand Up @@ -136,6 +138,9 @@ async def get_temporal_client(
connect_kwargs: dict[str, Any] = {
"target_host": temporal_address,
"plugins": plugins,
# Propagate OTel trace context on outbound start_workflow / execute_activity
# (enabled by default; AGENTEX_TEMPORAL_TRACE_INTERCEPTOR_ENABLED=false to disable).
"interceptors": temporal_tracing_interceptors(),
}

if data_converter is not None:
Expand Down
108 changes: 80 additions & 28 deletions src/agentex/lib/core/temporal/services/temporal_task_service.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,70 @@
from __future__ import annotations

import sys
from typing import Any
from datetime import timedelta
from contextlib import contextmanager
from collections.abc import Iterator

from agentex.types.task import Task
from agentex.types.agent import Agent
from agentex.types.event import Event
from agentex.protocol.acp import SendEventParams, CreateTaskParams, InterruptTaskParams
from agentex.lib.environment_variables import EnvironmentVariables
from agentex.lib.core.clients.temporal.types import WorkflowState
from agentex.lib.core.clients.temporal.types import WorkflowState, ConflictWorkflowPolicy
from agentex.lib.core.temporal.types.workflow import SignalName
from agentex.lib.core.clients.temporal.temporal_client import TemporalClient


@contextmanager
def _acp_dispatch_span(name: str, task_id: str | None = None) -> Iterator[None]:
"""Wrap an ACP -> Temporal dispatch (start_workflow / signal) in an OTel span.

The Temporal OpenTelemetry interceptor propagates trace context by injecting
the CURRENTLY ACTIVE span into the Temporal message headers on the caller
side (``start_workflow`` / ``signal_workflow``); the worker then extracts it
and roots the workflow / activity spans under it. But the ACP server dispatches
from a bare async handler with no active span, so nothing is injected and the
workflow's activities become DETACHED trace roots -- the business work shows up
in Tempo as a fresh trace with no link back to the ``task/create`` /
``event/send`` that triggered it.

Opening a span here gives the interceptor something to inject. It becomes a
child of the ingress request span when one is active (front-of-request
propagation), or a fresh per-turn root otherwise.

Fail-open across the WHOLE obs setup, not just the import: ``get_tracer`` and
entering ``start_as_current_span`` run the sampler and every
``SpanProcessor.on_start`` (the SDK does not guard those), so a broken
provider or a custom sampler/processor that raises would otherwise fail the
dispatch itself. If any of it fails we run the dispatch untraced. The dispatch
body (the ``yield``) is OUTSIDE the guard so its exceptions still propagate.
"""
span_cm = None
try:
from opentelemetry import trace as _otel_trace

tracer = _otel_trace.get_tracer("agentex.acp")
# task_id goes on an attribute, NOT in the span name: a per-task span name is
# high-cardinality and breaks span-name aggregation in Tempo.
attributes = {"agentex.task_id": task_id} if task_id else None
span_cm = tracer.start_as_current_span(name, kind=_otel_trace.SpanKind.PRODUCER, attributes=attributes)
span_cm.__enter__()
except Exception: # pragma: no cover - obs must never break a dispatch
span_cm = None

try:
yield
finally:
if span_cm is not None:
# Pass exc info so the span reflects a failed dispatch; guard __exit__
# so closing the span can never mask the dispatch outcome.
try:
span_cm.__exit__(*sys.exc_info())
except Exception: # pragma: no cover - best-effort close
pass


class TemporalTaskService:
"""
Submits Agent agent_tasks to the async runtime for execution.
Expand All @@ -26,7 +78,6 @@ def __init__(
self._temporal_client = temporal_client
self._env_vars = env_vars


async def submit_task(self, agent: Agent, task: Task, params: dict[str, Any] | None) -> str:
"""
Submit a task to the async runtime for execution.
Expand All @@ -37,22 +88,22 @@ async def submit_task(self, agent: Agent, task: Task, params: dict[str, Any] | N
# indefinitely, which long-lived chat/session agents rely on). A positive
# value bounds the whole continue-as-new chain's wall-clock lifetime.
timeout_seconds = self._env_vars.WORKFLOW_EXECUTION_TIMEOUT_SECONDS
execution_timeout = (
timedelta(seconds=timeout_seconds)
if timeout_seconds and timeout_seconds > 0
else None
)
return await self._temporal_client.start_workflow(
workflow=self._env_vars.WORKFLOW_NAME,
arg=CreateTaskParams(
agent=agent,
task=task,
params=params,
),
id=task.id,
task_queue=self._env_vars.WORKFLOW_TASK_QUEUE,
execution_timeout=execution_timeout,
)
execution_timeout = timedelta(seconds=timeout_seconds) if timeout_seconds and timeout_seconds > 0 else None
# USE_EXISTING makes task/create idempotent
# If same task ID is already running Temporal returns a handle to the existing run instead of raising WorkflowAlreadyStarted
with _acp_dispatch_span("acp.task_create", task_id=task.id):
return await self._temporal_client.start_workflow(
workflow=self._env_vars.WORKFLOW_NAME,
arg=CreateTaskParams(
agent=agent,
task=task,
params=params,
),
id=task.id,
task_queue=self._env_vars.WORKFLOW_TASK_QUEUE,
execution_timeout=execution_timeout,
conflict_policy=ConflictWorkflowPolicy.USE_EXISTING,
)

async def get_state(self, task_id: str) -> WorkflowState:
"""
Expand All @@ -63,16 +114,17 @@ async def get_state(self, task_id: str) -> WorkflowState:
)

async def send_event(self, agent: Agent, task: Task, event: Event, request: dict | None = None) -> None:
return await self._temporal_client.send_signal(
workflow_id=task.id,
signal=SignalName.RECEIVE_EVENT.value,
payload=SendEventParams(
agent=agent,
task=task,
event=event,
request=request,
).model_dump(),
)
with _acp_dispatch_span("acp.event_send", task_id=task.id):
return await self._temporal_client.send_signal(
workflow_id=task.id,
signal=SignalName.RECEIVE_EVENT.value,
payload=SendEventParams(
agent=agent,
task=task,
event=event,
request=request,
).model_dump(),
)

async def interrupt(self, agent: Agent, task: Task, request: dict | None = None) -> None:
"""Forward a task/interrupt to the running workflow as a dedicated signal.
Expand Down
8 changes: 7 additions & 1 deletion src/agentex/lib/core/temporal/workers/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@

from agentex.lib.utils.logging import make_logger
from agentex.lib.utils.registration import register_agent
from agentex.lib.core.tracing.temporal import temporal_tracing_interceptors
from agentex.lib.environment_variables import EnvironmentVariables
from agentex.lib.core.compat.version_guard import assert_backend_compatible

Expand Down Expand Up @@ -126,6 +127,9 @@ async def get_temporal_client(
connect_kwargs: dict[str, Any] = {
"target_host": temporal_address,
"plugins": plugins,
# Propagate OTel trace context on outbound start_workflow / execute_activity
# (enabled by default; AGENTEX_TEMPORAL_TRACE_INTERCEPTOR_ENABLED=false to disable).
"interceptors": temporal_tracing_interceptors(),
}

if data_converter is not None:
Expand Down Expand Up @@ -229,7 +233,9 @@ async def run(
max_concurrent_activities=self.max_concurrent_activities,
build_id=str(uuid.uuid4()),
debug_mode=debug_enabled, # Disable deadlock detection in debug mode
interceptors=self.interceptors, # Pass interceptors to Worker
# Tracing interceptor OUTERMOST so business interceptors (and the spans
# they create) nest under the propagated workflow/activity span.
interceptors=[*temporal_tracing_interceptors(), *self.interceptors],
)

logger.info(f"Starting workers for task queue: {self.task_queue}")
Expand Down
Loading
Loading