Skip to content

Commit 941bbc4

Browse files
max-parke-scaleclaudeNiteshDhanpalstainless-app[bot]
authored
feat(lineage): capture tool data-source refs and agent build version in span data (#469)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Nitesh Dhanpal <NiteshDhanpal@users.noreply.github.com> Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com>
1 parent 3d486e0 commit 941bbc4

11 files changed

Lines changed: 484 additions & 36 deletions

File tree

‎src/agentex/lib/adk/__init__.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@
2929
from agentex.lib.adk._modules.tasks import TasksModule
3030
from agentex.lib.adk._modules.tracing import TracingModule, TurnSpan
3131

32+
# Data-source refs for lineage (SGP-6513); implementation lives in core.tracing
33+
from agentex.lib.core.tracing import lineage
34+
from agentex.lib.core.tracing.lineage import DataSourceRef, data_sources
35+
3236
# Unified harness surface (AGX1-375)
3337
from agentex.lib.core.harness import (
3438
UnifiedEmitter,
@@ -67,6 +71,10 @@
6771
"events",
6872
"agent_task_tracker",
6973
"TurnSpan",
74+
# Lineage data-source refs (SGP-6513)
75+
"lineage",
76+
"DataSourceRef",
77+
"data_sources",
7078
# Checkpointing / LangGraph
7179
"create_checkpointer",
7280
"stream_langgraph_events",

‎src/agentex/lib/adk/providers/_modules/sync_provider.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from agentex import AsyncAgentex
2020
from agentex.lib.utils.logging import make_logger
2121
from agentex.lib.core.tracing.tracer import AsyncTracer
22+
from agentex.lib.core.tracing.lineage import merge_refs_into_data, resolve_refs_from_items
2223

2324
logger = make_logger(__name__)
2425

@@ -185,6 +186,9 @@ async def get_response(
185186
"new_items": new_items,
186187
"final_output": final_output,
187188
}
189+
lineage_refs = resolve_refs_from_items(new_items)
190+
if lineage_refs:
191+
span.data = merge_refs_into_data(span.data, lineage_refs)
188192

189193
return response
190194
else:
@@ -303,6 +307,9 @@ async def stream_response(
303307
"new_items": new_items,
304308
"final_output": final_response_text if final_response_text else None,
305309
}
310+
lineage_refs = resolve_refs_from_items(new_items)
311+
if lineage_refs:
312+
span.data = merge_refs_into_data(span.data, lineage_refs)
306313
finally:
307314
# End the span after all events have been yielded
308315
await trace.end_span(span)

‎src/agentex/lib/core/harness/tracer.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,17 @@
66

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

9+
try:
10+
from agentex.lib.core.tracing.lineage import resolve_refs, merge_refs_into_data
11+
except Exception: # keep the harness importable without optional tracing deps
12+
13+
def resolve_refs(tool_name: str, arguments: dict[str, Any] | None) -> list[dict[str, Any]]: # noqa: ARG001
14+
return []
15+
16+
def merge_refs_into_data(data: dict[str, Any] | None, refs: list[dict[str, Any]]) -> dict[str, Any]: # noqa: ARG001
17+
return dict(data or {})
18+
19+
920
try:
1021
from agentex.lib.utils.logging import make_logger
1122

@@ -80,6 +91,11 @@ async def handle(self, signal: SpanSignal) -> None:
8091
task_id=self.task_id,
8192
)
8293
if span is not None:
94+
if signal.kind == "tool":
95+
refs = resolve_refs(signal.name, signal.input if isinstance(signal.input, dict) else {})
96+
if refs:
97+
data = span.data if isinstance(span.data, dict) else {}
98+
span.data = merge_refs_into_data(data, refs)
8399
self._open[signal.key] = span
84100
elif isinstance(signal, CloseSpan):
85101
span = self._open.pop(signal.key, None)

‎src/agentex/lib/core/services/adk/providers/openai.py‎

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from agentex.lib.utils.temporal import heartbeat_if_in_workflow
2626
from agentex.lib.core.tracing.tracer import AsyncTracer
2727
from agentex.lib.core.harness.emitter import UnifiedEmitter
28+
from agentex.lib.core.tracing.lineage import merge_refs_into_data, resolve_refs_from_items
2829
from agentex.types.task_message_update import StreamTaskMessageFull
2930
from agentex.types.task_message_content import (
3031
TextContent,
@@ -286,13 +287,17 @@ async def run_agent(
286287
result = await Runner.run(starting_agent=agent, input=input_list)
287288

288289
if span:
290+
serialized_items = [
291+
item.raw_item.model_dump() if isinstance(item.raw_item, BaseModel) else item.raw_item
292+
for item in result.new_items
293+
]
289294
span.output = {
290-
"new_items": [
291-
item.raw_item.model_dump() if isinstance(item.raw_item, BaseModel) else item.raw_item
292-
for item in result.new_items
293-
],
295+
"new_items": serialized_items,
294296
"final_output": result.final_output,
295297
}
298+
lineage_refs = resolve_refs_from_items(serialized_items)
299+
if lineage_refs:
300+
span.data = merge_refs_into_data(span.data, lineage_refs)
296301

297302
return result
298303

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

433438
if span:
439+
serialized_items = [
440+
item.raw_item.model_dump() if isinstance(item.raw_item, BaseModel) else item.raw_item
441+
for item in result.new_items
442+
]
434443
span.output = {
435-
"new_items": [
436-
item.raw_item.model_dump() if isinstance(item.raw_item, BaseModel) else item.raw_item
437-
for item in result.new_items
438-
],
444+
"new_items": serialized_items,
439445
"final_output": result.final_output,
440446
}
447+
lineage_refs = resolve_refs_from_items(serialized_items)
448+
if lineage_refs:
449+
span.data = merge_refs_into_data(span.data, lineage_refs)
441450

442451
tool_call_map: dict[str, Any] = {}
443452

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

648657
if span:
658+
serialized_items = [
659+
item.raw_item.model_dump() if isinstance(item.raw_item, BaseModel) else item.raw_item
660+
for item in result.new_items
661+
]
649662
span.output = {
650-
"new_items": [
651-
item.raw_item.model_dump() if isinstance(item.raw_item, BaseModel) else item.raw_item
652-
for item in result.new_items
653-
],
663+
"new_items": serialized_items,
654664
"final_output": result.final_output,
655665
}
666+
lineage_refs = resolve_refs_from_items(serialized_items)
667+
if lineage_refs:
668+
span.data = merge_refs_into_data(span.data, lineage_refs)
656669

657670
return result
658671

@@ -906,12 +919,16 @@ async def run_agent_streamed_auto_send(
906919
raise
907920

908921
if span:
922+
serialized_items = [
923+
item.raw_item.model_dump() if isinstance(item.raw_item, BaseModel) else item.raw_item
924+
for item in result.new_items
925+
]
909926
span.output = {
910-
"new_items": [
911-
item.raw_item.model_dump() if isinstance(item.raw_item, BaseModel) else item.raw_item
912-
for item in result.new_items
913-
],
927+
"new_items": serialized_items,
914928
"final_output": result.final_output,
915929
}
930+
lineage_refs = resolve_refs_from_items(serialized_items)
931+
if lineage_refs:
932+
span.data = merge_refs_into_data(span.data, lineage_refs)
916933

917934
return result

‎src/agentex/lib/core/temporal/plugins/openai_agents/models/temporal_streaming_model.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
from agentex.lib import adk
6565
from agentex.lib.utils.logging import make_logger
6666
from agentex.lib.core.tracing.tracer import AsyncTracer
67+
from agentex.lib.core.tracing.lineage import merge_refs_into_data, resolve_refs_from_items
6768
from agentex.types.task_message_delta import TextDelta, ToolRequestDelta, ReasoningContentDelta, ReasoningSummaryDelta
6869
from agentex.types.task_message_update import StreamTaskMessageFull, StreamTaskMessageDelta
6970
from agentex.types.task_message_content import TextContent, ReasoningContent, ToolRequestContent, ToolResponseContent
@@ -1257,6 +1258,9 @@ async def get_response(
12571258
output_data["tool_outputs"] = tool_outputs
12581259

12591260
span.output = output_data
1261+
lineage_refs = resolve_refs_from_items(new_items)
1262+
if lineage_refs:
1263+
span.data = merge_refs_into_data(span.data, lineage_refs)
12601264

12611265
# Streaming-only metrics. Token counters and the success request
12621266
# counter are emitted by LLMMetricsHooks.on_llm_end so they fire
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
"""Data-source reference capture for lineage: tools declare which sources they
2+
touch and the refs land in span data under the ``sgp.lineage.refs`` key."""
3+
4+
from __future__ import annotations
5+
6+
import re
7+
import json
8+
from typing import Any, Literal, Callable, Iterable
9+
10+
from pydantic import Field, BaseModel, field_validator
11+
12+
try:
13+
from agentex.lib.utils.logging import make_logger
14+
15+
logger = make_logger(__name__)
16+
except Exception: # ddtrace may be absent in some envs; fall back to stdlib
17+
import logging
18+
19+
logger = logging.getLogger(__name__)
20+
21+
LINEAGE_REFS_KEY = "sgp.lineage.refs"
22+
23+
# The URI arm of the lineage namespace identifier rule (namespace-conventions.md):
24+
# lowercase scheme and host (dots/hyphens only — normalize `_` to `-`), one optional path segment.
25+
_URI_NAMESPACE_RE = re.compile(r"^[a-z][a-z0-9._-]*://[a-z0-9.-]+(/[a-zA-Z0-9._-]*)?$")
26+
27+
RefResolver = Callable[[dict[str, Any]], "list[DataSourceRef]"]
28+
29+
30+
class DataSourceRef(BaseModel):
31+
"""One data source a tool call touched, as a lineage coordinate."""
32+
33+
namespace: str = Field(max_length=512)
34+
name: str = Field(min_length=1, max_length=512)
35+
version: str | None = Field(default=None, max_length=256)
36+
role: Literal["input", "output"] = "input"
37+
38+
def __init__(self, namespace: str | None = None, name: str | None = None, **kwargs: Any) -> None:
39+
if namespace is not None:
40+
kwargs["namespace"] = namespace
41+
if name is not None:
42+
kwargs["name"] = name
43+
super().__init__(**kwargs)
44+
45+
@field_validator("namespace")
46+
@classmethod
47+
def _namespace_is_uri_form(cls, value: str) -> str:
48+
if not _URI_NAMESPACE_RE.match(value):
49+
raise ValueError(f"namespace must be URI-form (scheme://system), got: {value!r}")
50+
return value
51+
52+
53+
class _ToolSources(BaseModel):
54+
refs: list[DataSourceRef] = Field(default_factory=list)
55+
resolver: RefResolver | None = None
56+
57+
model_config = {"arbitrary_types_allowed": True}
58+
59+
60+
_tool_sources: dict[str, _ToolSources] = {}
61+
62+
63+
def register_tool_sources(
64+
tool_name: str,
65+
refs: Iterable[DataSourceRef] | None = None,
66+
resolver: RefResolver | None = None,
67+
) -> None:
68+
"""Declare the data sources a tool touches, keyed by its tool name.
69+
70+
Use for tools the agent does not own (e.g. MCP proxy tools). Static refs and
71+
a resolver over the tool's parsed arguments may be combined; repeated
72+
registration for the same name replaces the prior entry. The registry is
73+
process-wide: co-located agents sharing a tool name share (and overwrite)
74+
one entry, so disambiguate shared names before co-locating agent types.
75+
"""
76+
_tool_sources[tool_name] = _ToolSources(refs=list(refs or []), resolver=resolver)
77+
78+
79+
def data_sources(*refs: DataSourceRef, resolver: RefResolver | None = None) -> Callable[[Any], Any]:
80+
"""Decorator form of ``register_tool_sources`` for tools the agent owns.
81+
82+
Works below or above ``@function_tool``: the tool name is taken from the
83+
decorated object's ``name`` attribute when present, else ``__name__``.
84+
"""
85+
86+
def _register(obj: Any) -> Any:
87+
tool_name = getattr(obj, "name", None) or getattr(obj, "__name__", None)
88+
if isinstance(tool_name, str) and tool_name:
89+
register_tool_sources(tool_name, refs=refs, resolver=resolver)
90+
else:
91+
logger.warning("data_sources could not determine a tool name for %r; refs not registered", obj)
92+
return obj
93+
94+
return _register
95+
96+
97+
def clear_tool_sources() -> None:
98+
"""Reset the registry (test isolation)."""
99+
_tool_sources.clear()
100+
101+
102+
def resolve_refs(tool_name: str, arguments: dict[str, Any] | None) -> list[dict[str, Any]]:
103+
"""Resolve registered refs for one tool call to serialized, deduplicated dicts.
104+
105+
Resolver failures are logged and swallowed: ref capture must never break a
106+
tool call or its tracing.
107+
"""
108+
entry = _tool_sources.get(tool_name)
109+
if entry is None:
110+
return []
111+
refs = list(entry.refs)
112+
if entry.resolver is not None:
113+
try:
114+
refs.extend(entry.resolver(arguments or {}))
115+
except Exception:
116+
logger.warning("data-source resolver for tool %s failed; static refs kept", tool_name, exc_info=True)
117+
return _dedupe(refs)
118+
119+
120+
def resolve_refs_from_items(items: Iterable[Any]) -> list[dict[str, Any]]:
121+
"""Resolve refs across serialized run items, matching ``function_call`` entries.
122+
123+
Accepts the item dicts the providers already build for span output; string
124+
``arguments`` are parsed as JSON for resolver-based registrations.
125+
"""
126+
refs: list[dict[str, Any]] = []
127+
for item in items:
128+
if not isinstance(item, dict) or item.get("type") != "function_call":
129+
continue
130+
tool_name = item.get("name")
131+
if not isinstance(tool_name, str) or not tool_name:
132+
continue
133+
arguments = item.get("arguments")
134+
if isinstance(arguments, str):
135+
try:
136+
arguments = json.loads(arguments)
137+
except (ValueError, TypeError):
138+
arguments = {}
139+
refs.extend(resolve_refs(tool_name, arguments if isinstance(arguments, dict) else {}))
140+
return _dedupe_dicts(refs)
141+
142+
143+
def record(span: Any, refs: Iterable[DataSourceRef]) -> None:
144+
"""Attach refs to a manually managed span (no-op when the span is None)."""
145+
if span is None:
146+
return
147+
merged = merge_refs_into_data(getattr(span, "data", None), _dedupe(list(refs)))
148+
span.data = merged
149+
150+
151+
def merge_refs_into_data(data: dict[str, Any] | None, refs: list[dict[str, Any]]) -> dict[str, Any]:
152+
"""Merge serialized refs into a span data dict, deduplicating with any present."""
153+
out = dict(data) if isinstance(data, dict) else {}
154+
if refs:
155+
existing = out.get(LINEAGE_REFS_KEY)
156+
combined = list(existing) if isinstance(existing, list) else []
157+
combined.extend(refs)
158+
out[LINEAGE_REFS_KEY] = _dedupe_dicts(combined)
159+
return out
160+
161+
162+
def _dedupe(refs: list[DataSourceRef]) -> list[dict[str, Any]]:
163+
return _dedupe_dicts([ref.model_dump(exclude_none=True) for ref in refs])
164+
165+
166+
def _dedupe_dicts(refs: list[dict[str, Any]]) -> list[dict[str, Any]]:
167+
seen: set[tuple[Any, ...]] = set()
168+
out: list[dict[str, Any]] = []
169+
for ref in refs:
170+
key = (ref.get("namespace"), ref.get("name"), ref.get("version"), ref.get("role"))
171+
if key not in seen:
172+
seen.add(key)
173+
out.append(ref)
174+
return out

‎src/agentex/lib/core/tracing/processors/sgp_tracing_processor.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ def _add_source_to_span(span: Span, env_vars: EnvironmentVariables) -> None:
6565
span.data["__agent_name__"] = env_vars.AGENT_NAME
6666
if env_vars.AGENT_ID is not None:
6767
span.data["__agent_id__"] = env_vars.AGENT_ID
68+
if env_vars.AGENT_VERSION is not None:
69+
span.data["__agent_version__"] = env_vars.AGENT_VERSION
6870

6971

7072
def _build_sgp_span(span: Span, env_vars: EnvironmentVariables) -> SGPSpan:

‎src/agentex/lib/environment_variables.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ class EnvVarKeys(str, Enum):
2424
AGENT_NAME = "AGENT_NAME"
2525
AGENT_DESCRIPTION = "AGENT_DESCRIPTION"
2626
AGENT_ID = "AGENT_ID"
27+
AGENT_VERSION = "AGENT_VERSION"
2728
AGENT_API_KEY = "AGENT_API_KEY"
2829
# ACP Configuration
2930
ACP_URL = "ACP_URL"
@@ -64,6 +65,8 @@ class EnvironmentVariables(BaseModel):
6465
AGENT_NAME: str
6566
AGENT_DESCRIPTION: str | None = None
6667
AGENT_ID: str | None = None
68+
# Build/version discriminator (image tag or git sha), set by the deployment
69+
AGENT_VERSION: str | None = None
6770
AGENT_API_KEY: str | None = None
6871
ACP_TYPE: str | None = "async"
6972
AGENT_INPUT_TYPE: str | None = None

0 commit comments

Comments
 (0)