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
8 changes: 8 additions & 0 deletions src/agentex/lib/adk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@
from agentex.lib.adk._modules.tasks import TasksModule
from agentex.lib.adk._modules.tracing import TracingModule, TurnSpan

# Data-source refs for lineage (SGP-6513); implementation lives in core.tracing
from agentex.lib.core.tracing import lineage
from agentex.lib.core.tracing.lineage import DataSourceRef, data_sources

# Unified harness surface (AGX1-375)
from agentex.lib.core.harness import (
UnifiedEmitter,
Expand Down Expand Up @@ -67,6 +71,10 @@
"events",
"agent_task_tracker",
"TurnSpan",
# Lineage data-source refs (SGP-6513)
"lineage",
"DataSourceRef",
"data_sources",
# Checkpointing / LangGraph
"create_checkpointer",
"stream_langgraph_events",
Expand Down
7 changes: 7 additions & 0 deletions src/agentex/lib/adk/providers/_modules/sync_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from agentex import AsyncAgentex
from agentex.lib.utils.logging import make_logger
from agentex.lib.core.tracing.tracer import AsyncTracer
from agentex.lib.core.tracing.lineage import merge_refs_into_data, resolve_refs_from_items

logger = make_logger(__name__)

Expand Down Expand Up @@ -185,6 +186,9 @@ async def get_response(
"new_items": new_items,
"final_output": final_output,
}
lineage_refs = resolve_refs_from_items(new_items)
if lineage_refs:
span.data = merge_refs_into_data(span.data, lineage_refs)

return response
else:
Expand Down Expand Up @@ -303,6 +307,9 @@ async def stream_response(
"new_items": new_items,
"final_output": final_response_text if final_response_text else None,
}
lineage_refs = resolve_refs_from_items(new_items)
if lineage_refs:
span.data = merge_refs_into_data(span.data, lineage_refs)
finally:
# End the span after all events have been yielded
await trace.end_span(span)
Expand Down
16 changes: 16 additions & 0 deletions src/agentex/lib/core/harness/tracer.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@

from agentex.lib.core.harness.types import OpenSpan, CloseSpan, SpanSignal

try:
from agentex.lib.core.tracing.lineage import resolve_refs, merge_refs_into_data
except Exception: # keep the harness importable without optional tracing deps

def resolve_refs(tool_name: str, arguments: dict[str, Any] | None) -> list[dict[str, Any]]: # noqa: ARG001
return []

def merge_refs_into_data(data: dict[str, Any] | None, refs: list[dict[str, Any]]) -> dict[str, Any]: # noqa: ARG001
return dict(data or {})


try:
from agentex.lib.utils.logging import make_logger

Expand Down Expand Up @@ -80,6 +91,11 @@ async def handle(self, signal: SpanSignal) -> None:
task_id=self.task_id,
)
if span is not None:
if signal.kind == "tool":
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)
self._open[signal.key] = span
elif isinstance(signal, CloseSpan):
span = self._open.pop(signal.key, None)
Expand Down
49 changes: 33 additions & 16 deletions src/agentex/lib/core/services/adk/providers/openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from agentex.lib.utils.temporal import heartbeat_if_in_workflow
from agentex.lib.core.tracing.tracer import AsyncTracer
from agentex.lib.core.harness.emitter import UnifiedEmitter
from agentex.lib.core.tracing.lineage import merge_refs_into_data, resolve_refs_from_items
from agentex.types.task_message_update import StreamTaskMessageFull
from agentex.types.task_message_content import (
TextContent,
Expand Down Expand Up @@ -286,13 +287,17 @@ async def run_agent(
result = await Runner.run(starting_agent=agent, input=input_list)

if span:
serialized_items = [
item.raw_item.model_dump() if isinstance(item.raw_item, BaseModel) else item.raw_item
for item in result.new_items
]
span.output = {
"new_items": [
item.raw_item.model_dump() if isinstance(item.raw_item, BaseModel) else item.raw_item
for item in result.new_items
],
"new_items": serialized_items,
"final_output": result.final_output,
}
lineage_refs = resolve_refs_from_items(serialized_items)
if lineage_refs:
span.data = merge_refs_into_data(span.data, lineage_refs)

return result

Expand Down Expand Up @@ -431,13 +436,17 @@ async def run_agent_auto_send(
result = await Runner.run(starting_agent=agent, input=input_list)

if span:
serialized_items = [
item.raw_item.model_dump() if isinstance(item.raw_item, BaseModel) else item.raw_item
for item in result.new_items
]
span.output = {
"new_items": [
item.raw_item.model_dump() if isinstance(item.raw_item, BaseModel) else item.raw_item
for item in result.new_items
],
"new_items": serialized_items,
"final_output": result.final_output,
}
lineage_refs = resolve_refs_from_items(serialized_items)
if lineage_refs:
span.data = merge_refs_into_data(span.data, lineage_refs)

tool_call_map: dict[str, Any] = {}

Expand Down Expand Up @@ -646,13 +655,17 @@ async def run_agent_streamed(
result = Runner.run_streamed(starting_agent=agent, input=input_list)

if span:
serialized_items = [
item.raw_item.model_dump() if isinstance(item.raw_item, BaseModel) else item.raw_item
for item in result.new_items
]
span.output = {
"new_items": [
item.raw_item.model_dump() if isinstance(item.raw_item, BaseModel) else item.raw_item
for item in result.new_items
],
"new_items": serialized_items,
"final_output": result.final_output,
}
lineage_refs = resolve_refs_from_items(serialized_items)
if lineage_refs:
span.data = merge_refs_into_data(span.data, lineage_refs)

return result

Expand Down Expand Up @@ -906,12 +919,16 @@ async def run_agent_streamed_auto_send(
raise

if span:
serialized_items = [
item.raw_item.model_dump() if isinstance(item.raw_item, BaseModel) else item.raw_item
for item in result.new_items
]
span.output = {
"new_items": [
item.raw_item.model_dump() if isinstance(item.raw_item, BaseModel) else item.raw_item
for item in result.new_items
],
"new_items": serialized_items,
"final_output": result.final_output,
}
lineage_refs = resolve_refs_from_items(serialized_items)
if lineage_refs:
span.data = merge_refs_into_data(span.data, lineage_refs)

return result
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
from agentex.lib import adk
from agentex.lib.utils.logging import make_logger
from agentex.lib.core.tracing.tracer import AsyncTracer
from agentex.lib.core.tracing.lineage import merge_refs_into_data, resolve_refs_from_items
from agentex.types.task_message_delta import TextDelta, ToolRequestDelta, ReasoningContentDelta, ReasoningSummaryDelta
from agentex.types.task_message_update import StreamTaskMessageFull, StreamTaskMessageDelta
from agentex.types.task_message_content import TextContent, ReasoningContent, ToolRequestContent, ToolResponseContent
Expand Down Expand Up @@ -1257,6 +1258,9 @@ async def get_response(
output_data["tool_outputs"] = tool_outputs

span.output = output_data
lineage_refs = resolve_refs_from_items(new_items)
if lineage_refs:
span.data = merge_refs_into_data(span.data, lineage_refs)

# Streaming-only metrics. Token counters and the success request
# counter are emitted by LLMMetricsHooks.on_llm_end so they fire
Expand Down
174 changes: 174 additions & 0 deletions src/agentex/lib/core/tracing/lineage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
"""Data-source reference capture for lineage: tools declare which sources they
touch and the refs land in span data under the ``sgp.lineage.refs`` key."""

from __future__ import annotations

import re
import json
from typing import Any, Literal, Callable, Iterable

from pydantic import Field, BaseModel, field_validator

try:
from agentex.lib.utils.logging import make_logger

logger = make_logger(__name__)
except Exception: # ddtrace may be absent in some envs; fall back to stdlib
import logging

logger = logging.getLogger(__name__)

LINEAGE_REFS_KEY = "sgp.lineage.refs"

# The URI arm of the lineage namespace identifier rule (namespace-conventions.md):
# lowercase scheme and host (dots/hyphens only — normalize `_` to `-`), one optional path segment.
_URI_NAMESPACE_RE = re.compile(r"^[a-z][a-z0-9._-]*://[a-z0-9.-]+(/[a-zA-Z0-9._-]*)?$")

RefResolver = Callable[[dict[str, Any]], "list[DataSourceRef]"]


class DataSourceRef(BaseModel):
"""One data source a tool call touched, as a lineage coordinate."""

namespace: str = Field(max_length=512)
name: str = Field(min_length=1, max_length=512)
version: str | None = Field(default=None, max_length=256)
role: Literal["input", "output"] = "input"

def __init__(self, namespace: str | None = None, name: str | None = None, **kwargs: Any) -> None:
if namespace is not None:
kwargs["namespace"] = namespace
if name is not None:
kwargs["name"] = name
super().__init__(**kwargs)

@field_validator("namespace")
@classmethod
def _namespace_is_uri_form(cls, value: str) -> str:
if not _URI_NAMESPACE_RE.match(value):
raise ValueError(f"namespace must be URI-form (scheme://system), got: {value!r}")
return value


class _ToolSources(BaseModel):
refs: list[DataSourceRef] = Field(default_factory=list)
resolver: RefResolver | None = None

model_config = {"arbitrary_types_allowed": True}


_tool_sources: dict[str, _ToolSources] = {}
Comment thread
greptile-apps[bot] marked this conversation as resolved.


def register_tool_sources(
tool_name: str,
refs: Iterable[DataSourceRef] | None = None,
resolver: RefResolver | None = None,
) -> None:
"""Declare the data sources a tool touches, keyed by its tool name.

Use for tools the agent does not own (e.g. MCP proxy tools). Static refs and
a resolver over the tool's parsed arguments may be combined; repeated
registration for the same name replaces the prior entry. The registry is
process-wide: co-located agents sharing a tool name share (and overwrite)
one entry, so disambiguate shared names before co-locating agent types.
"""
_tool_sources[tool_name] = _ToolSources(refs=list(refs or []), resolver=resolver)


def data_sources(*refs: DataSourceRef, resolver: RefResolver | None = None) -> Callable[[Any], Any]:
"""Decorator form of ``register_tool_sources`` for tools the agent owns.

Works below or above ``@function_tool``: the tool name is taken from the
decorated object's ``name`` attribute when present, else ``__name__``.
"""

def _register(obj: Any) -> Any:
tool_name = getattr(obj, "name", None) or getattr(obj, "__name__", None)
if isinstance(tool_name, str) and tool_name:
register_tool_sources(tool_name, refs=refs, resolver=resolver)
else:
logger.warning("data_sources could not determine a tool name for %r; refs not registered", obj)
return obj

return _register


def clear_tool_sources() -> None:
"""Reset the registry (test isolation)."""
_tool_sources.clear()


def resolve_refs(tool_name: str, arguments: dict[str, Any] | None) -> list[dict[str, Any]]:
"""Resolve registered refs for one tool call to serialized, deduplicated dicts.

Resolver failures are logged and swallowed: ref capture must never break a
tool call or its tracing.
"""
entry = _tool_sources.get(tool_name)
if entry is None:
return []
refs = list(entry.refs)
if entry.resolver is not None:
try:
refs.extend(entry.resolver(arguments or {}))
except Exception:
logger.warning("data-source resolver for tool %s failed; static refs kept", tool_name, exc_info=True)
return _dedupe(refs)


def resolve_refs_from_items(items: Iterable[Any]) -> list[dict[str, Any]]:
"""Resolve refs across serialized run items, matching ``function_call`` entries.

Accepts the item dicts the providers already build for span output; string
``arguments`` are parsed as JSON for resolver-based registrations.
"""
refs: list[dict[str, Any]] = []
for item in items:
if not isinstance(item, dict) or item.get("type") != "function_call":
continue
tool_name = item.get("name")
if not isinstance(tool_name, str) or not tool_name:
continue
arguments = item.get("arguments")
if isinstance(arguments, str):
try:
arguments = json.loads(arguments)
except (ValueError, TypeError):
arguments = {}
refs.extend(resolve_refs(tool_name, arguments if isinstance(arguments, dict) else {}))
return _dedupe_dicts(refs)


def record(span: Any, refs: Iterable[DataSourceRef]) -> None:
"""Attach refs to a manually managed span (no-op when the span is None)."""
if span is None:
return
merged = merge_refs_into_data(getattr(span, "data", None), _dedupe(list(refs)))
span.data = merged


def merge_refs_into_data(data: dict[str, Any] | None, refs: list[dict[str, Any]]) -> dict[str, Any]:
"""Merge serialized refs into a span data dict, deduplicating with any present."""
out = dict(data) if isinstance(data, dict) else {}
if refs:
existing = out.get(LINEAGE_REFS_KEY)
combined = list(existing) if isinstance(existing, list) else []
combined.extend(refs)
out[LINEAGE_REFS_KEY] = _dedupe_dicts(combined)
return out


def _dedupe(refs: list[DataSourceRef]) -> list[dict[str, Any]]:
return _dedupe_dicts([ref.model_dump(exclude_none=True) for ref in refs])


def _dedupe_dicts(refs: list[dict[str, Any]]) -> list[dict[str, Any]]:
seen: set[tuple[Any, ...]] = set()
out: list[dict[str, Any]] = []
for ref in refs:
key = (ref.get("namespace"), ref.get("name"), ref.get("version"), ref.get("role"))
if key not in seen:
seen.add(key)
out.append(ref)
return out
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ def _add_source_to_span(span: Span, env_vars: EnvironmentVariables) -> None:
span.data["__agent_name__"] = env_vars.AGENT_NAME
if env_vars.AGENT_ID is not None:
span.data["__agent_id__"] = env_vars.AGENT_ID
if env_vars.AGENT_VERSION is not None:
span.data["__agent_version__"] = env_vars.AGENT_VERSION


def _build_sgp_span(span: Span, env_vars: EnvironmentVariables) -> SGPSpan:
Expand Down
3 changes: 3 additions & 0 deletions src/agentex/lib/environment_variables.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class EnvVarKeys(str, Enum):
AGENT_NAME = "AGENT_NAME"
AGENT_DESCRIPTION = "AGENT_DESCRIPTION"
AGENT_ID = "AGENT_ID"
AGENT_VERSION = "AGENT_VERSION"
AGENT_API_KEY = "AGENT_API_KEY"
# ACP Configuration
ACP_URL = "ACP_URL"
Expand Down Expand Up @@ -66,6 +67,8 @@ class EnvironmentVariables(BaseModel):
AGENT_NAME: str
AGENT_DESCRIPTION: str | None = None
AGENT_ID: str | None = None
# Build/version discriminator (image tag or git sha), set by the deployment
AGENT_VERSION: str | None = None
AGENT_API_KEY: str | None = None
ACP_TYPE: str | None = "async"
AGENT_INPUT_TYPE: str | None = None
Expand Down
Loading
Loading