Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
7dc3c23
feat(tracing): add failure-check span attribute helper
mohammadatallah-scale Oct 26, 2026
1651347
fix(tracing): write the output schema attribute as JSON text
mohammadatallah-scale Oct 26, 2026
dbb8ff6
fix(tracing): validate the output schema again when attributes render
mohammadatallah-scale Oct 27, 2026
c537996
docs(tracing): guard the yielded span in the failure-check example
mohammadatallah-scale Oct 27, 2026
815dad0
test(tracing): pin the attribute each validation error names
mohammadatallah-scale Oct 27, 2026
c2954a8
test(tracing): name the nesting depth in the deep schema test
mohammadatallah-scale Oct 27, 2026
7b18bb8
fix(tracing): say that nested tool argument paths are not supported
mohammadatallah-scale Oct 27, 2026
2118a97
fix(tracing): name escalates in its span id error
mohammadatallah-scale Oct 27, 2026
57b161d
fix(tracing): reject non-string keys in the output schema
mohammadatallah-scale Oct 27, 2026
56d747d
fix(tracing): mark harness tool spans as tool calls
mohammadatallah-scale Oct 28, 2026
2417e34
fix(tracing): record non-string OpenAI tool results as JSON
mohammadatallah-scale Oct 28, 2026
8af1a38
fix(tracing): let one escalation name every failure it escalates
mohammadatallah-scale Oct 29, 2026
410c7f5
fix(tracing): mark a span cancelled mid-call as failed
mohammadatallah-scale Oct 29, 2026
769485c
feat(tracing): stamp the model id on model call spans
mohammadatallah-scale Oct 29, 2026
8d8c9b3
test(tracing): sort the span error test imports
mohammadatallah-scale Oct 29, 2026
4472989
test(tracing): drop a docstring that restates its helper
mohammadatallah-scale Oct 29, 2026
f12a356
feat(tracing): write failure-check attributes onto OpenTelemetry spans
mohammadatallah-scale Oct 29, 2026
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
41 changes: 41 additions & 0 deletions examples/tutorials/10_async/00_base/030_tracing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,47 @@ spans stay safe to emit. Recognized token keys: `input_tokens`/`prompt_tokens`,
`output_tokens`/`completion_tokens`, `cached_input_tokens`/`cached_tokens`,
`reasoning_tokens`; cost is `cost_usd`.

## Failure-Check Attributes

Offline failure checks read attributes the agent declares on its spans. Build them from one
spec instead of hand-writing keys. The spec validates its values on construction, and each
attribute lands in span metadata as a flat `sgp.agent.*` key holding a native JSON value (a
list stays a list and a step budget stays an integer). The output schema is the exception and
is written as a JSON string, because the span store would spread a schema object into many paths.

```python
import dataclasses

spec = adk.FailureCheckSpec(
required_outputs=["answer"],
step_budget=20,
verification=[adk.VerificationRule(step="check_answer", verifies="answer")],
escalation_channels=["send_email.to"],
prompt_version="2026-10-26",
)
async with adk.tracing.span(trace_id=task.id, name="agent", data=spec.attributes()) as root:
...
# The stop reason is only known at the end.
done = dataclasses.replace(spec, stop_reason="completed")
if root:
root.data = {**(root.data or {}), **done.attributes()}
```

A span that produces a required output, runs a verification or escalates one carries a single
attribute, passed as its `data`: `adk.failure_attributes.output_name("answer")`,
`verification_result("pass")` (or `"fail"`) and `escalates(verify_span.id)`, which takes several
span ids when one escalation covers several failures. The span
`adk.tracing.span` yields is `None` when `trace_id` is empty or the span fails to start, so
guard it before reading `.data` or `.id`.

An OpenTelemetry producer passes any of these dicts through `failure_attributes.otel_attributes`
before setting them on a span, and names an OpenTelemetry span to `escalates` as
`failure_attributes.otel_span_id(span)`.

The SGP export adds `__agent_version__` when `AGENT_VERSION` is set, and `__commit_sha__` when the
process starts with `AGENT_COMMIT_SHA` set to a valid git SHA, so only `prompt_version` is yours to
declare.

## When to Use
- Debugging complex agent behaviors
- Performance optimization and bottleneck identification
Expand Down
8 changes: 8 additions & 0 deletions src/agentex/lib/adk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@
from agentex.lib.core.tracing import code_revision
from agentex.lib.core.tracing.lineage import DataSourceRef, data_sources

# Span attributes the offline failure checks read, implemented in core.tracing
from agentex.lib.core.tracing import failure_attributes
from agentex.lib.core.tracing.failure_attributes import FailureCheckSpec, VerificationRule

# Unified harness surface (AGX1-375)
from agentex.lib.core.harness import (
UnifiedEmitter,
Expand Down Expand Up @@ -79,6 +83,10 @@
"code_revision",
"DataSourceRef",
"data_sources",
# Failure-check span attributes
"failure_attributes",
"FailureCheckSpec",
"VerificationRule",
# Checkpointing / LangGraph
"create_checkpointer",
"stream_langgraph_events",
Expand Down
2 changes: 1 addition & 1 deletion src/agentex/lib/adk/_modules/tracing.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ async def span(
)
try:
yield span
except Exception as exc:
except BaseException 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
Expand Down
10 changes: 8 additions & 2 deletions src/agentex/lib/core/harness/tracer.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ def merge_refs_into_data(data: dict[str, Any] | None, refs: list[dict[str, Any]]

logger = logging.getLogger(__name__)

_OPERATION_NAME_KEY = "gen_ai.operation.name"
_TOOL_NAME_KEY = "gen_ai.tool.name"
_EXECUTE_TOOL = "execute_tool"


def _as_span_payload(value: Any, *, key: str) -> Any:
"""Coerce a span input/output payload into a dict.
Expand Down Expand Up @@ -92,10 +96,12 @@ async def handle(self, signal: SpanSignal) -> None:
)
if span is not None:
if signal.kind == "tool":
data = span.data if isinstance(span.data, dict) else {}
refs = resolve_refs(signal.name, signal.input if isinstance(signal.input, dict) else {})
if refs:
data = span.data if isinstance(span.data, dict) else {}
span.data = merge_refs_into_data(data, refs)
data = merge_refs_into_data(data, refs)
# A successful call reports no is_error, so the failure checks recognise a tool by these keys.
span.data = {**data, _OPERATION_NAME_KEY: _EXECUTE_TOOL, _TOOL_NAME_KEY: signal.name}
self._open[signal.key] = span
elif isinstance(signal, CloseSpan):
span = self._open.pop(signal.key, None)
Expand Down
6 changes: 6 additions & 0 deletions src/agentex/lib/core/services/adk/providers/litellm.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@

logger = logging.make_logger(__name__)

_REQUEST_MODEL_KEY = "gen_ai.request.model"


def _stream_kwargs_with_usage(llm_config: LLMConfig) -> dict:
"""Completion kwargs with usage reporting enabled on the final stream chunk.
Expand Down Expand Up @@ -63,6 +65,7 @@ async def chat_completion(
parent_id=parent_span_id,
name="chat_completion",
input=llm_config.model_dump(),
data={_REQUEST_MODEL_KEY: llm_config.model},
) as span:
heartbeat_if_in_workflow("chat completion")
if self.llm_gateway is None:
Expand Down Expand Up @@ -104,6 +107,7 @@ async def chat_completion_auto_send(
parent_id=parent_span_id,
name="chat_completion_auto_send",
input=llm_config.model_dump(),
data={_REQUEST_MODEL_KEY: llm_config.model},
) as span:
heartbeat_if_in_workflow("chat completion auto send")

Expand Down Expand Up @@ -174,6 +178,7 @@ async def chat_completion_stream(
parent_id=parent_span_id,
name="chat_completion_stream",
input=completion_kwargs,
data={_REQUEST_MODEL_KEY: llm_config.model},
) as span:
# Direct streaming outside temporal - yield each chunk as it comes
chunks: list[Completion] = []
Expand Down Expand Up @@ -217,6 +222,7 @@ async def chat_completion_stream_auto_send(
parent_id=parent_span_id,
name="chat_completion_stream_auto_send",
input=completion_kwargs,
data={_REQUEST_MODEL_KEY: llm_config.model},
) as span:
# Use streaming context manager
async with self.streaming_service.streaming_task_message_context(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,16 @@
_MAX_SPAN_OUTPUT_CHARS = 2000


def _result_text(result: Any) -> str:
"""The failure checks decode a result as JSON, so ``str`` is only the fallback when it cannot be serialized."""
if isinstance(result, str):
return result
try:
return json.dumps(result)
except (TypeError, ValueError):
return str(result)


def _get_adk() -> Any:
"""Lazily import the adk facade for workflow-safe tracing.

Expand Down Expand Up @@ -355,7 +365,7 @@ async def _maybe_end_tool_span(self, tool_call_id: str, result: Any) -> None:
if span is None or not self.trace_id:
return
try:
span.output = {"result": str(result)[:_MAX_SPAN_OUTPUT_CHARS]}
span.output = {"result": _result_text(result)[:_MAX_SPAN_OUTPUT_CHARS]}
await _get_adk().tracing.end_span(
trace_id=self.trace_id,
span=span,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ def _serialize_item(item: Any) -> dict[str, Any]:

# Cap on the rendered hosted-tool result string (UI / trace readability).
_HOSTED_TOOL_RESULT_CAP = 2000
_REQUEST_MODEL_KEY = "gen_ai.request.model"


def _coerce_args(raw: Any) -> dict[str, Any]:
Expand Down Expand Up @@ -652,6 +653,7 @@ async def get_response(
"tools_count": len(tools) if tools else 0,
"handoffs_count": len(handoffs) if handoffs else 0,
},
data={_REQUEST_MODEL_KEY: self.model_name},
) as span:
# Always use Responses API for streaming
if not task_id:
Expand Down
Loading
Loading