trodo-python 2.13.0__tar.gz → 2.15.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {trodo_python-2.13.0 → trodo_python-2.15.0}/PKG-INFO +1 -1
- {trodo_python-2.13.0 → trodo_python-2.15.0}/pyproject.toml +1 -1
- trodo_python-2.15.0/tests/test_prompt_trace.py +107 -0
- {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/__init__.py +1 -1
- {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/managers/prompt_manager.py +22 -9
- {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/otel/auto_instrument.py +9 -0
- {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/otel/context.py +4 -0
- {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/otel/processor.py +3 -0
- trodo_python-2.15.0/trodo/otel/prompt_trace.py +94 -0
- {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/otel/wrap_agent.py +17 -2
- {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/prompts/types.py +30 -1
- {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo_python.egg-info/PKG-INFO +1 -1
- {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo_python.egg-info/SOURCES.txt +2 -0
- {trodo_python-2.13.0 → trodo_python-2.15.0}/README.md +0 -0
- {trodo_python-2.13.0 → trodo_python-2.15.0}/setup.cfg +0 -0
- {trodo_python-2.13.0 → trodo_python-2.15.0}/tests/test_anon_distinct_id.py +0 -0
- {trodo_python-2.13.0 → trodo_python-2.15.0}/tests/test_auto_instrument_fixes.py +0 -0
- {trodo_python-2.13.0 → trodo_python-2.15.0}/tests/test_cross_process_session.py +0 -0
- {trodo_python-2.13.0 → trodo_python-2.15.0}/tests/test_end_run.py +0 -0
- {trodo_python-2.13.0 → trodo_python-2.15.0}/tests/test_error_enrichment.py +0 -0
- {trodo_python-2.13.0 → trodo_python-2.15.0}/tests/test_llm_usage_cost.py +0 -0
- {trodo_python-2.13.0 → trodo_python-2.15.0}/tests/test_processor_methods.py +0 -0
- {trodo_python-2.13.0 → trodo_python-2.15.0}/tests/test_prompts.py +0 -0
- {trodo_python-2.13.0 → trodo_python-2.15.0}/tests/test_register_otel.py +0 -0
- {trodo_python-2.13.0 → trodo_python-2.15.0}/tests/test_start_run.py +0 -0
- {trodo_python-2.13.0 → trodo_python-2.15.0}/tests/test_wrap_agent_unchanged.py +0 -0
- {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/api/__init__.py +0 -0
- {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/api/async_client.py +0 -0
- {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/api/endpoints.py +0 -0
- {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/api/http_client.py +0 -0
- {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/auto/__init__.py +0 -0
- {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/auto/auto_event_manager.py +0 -0
- {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/client.py +0 -0
- {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/managers/__init__.py +0 -0
- {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/managers/group_manager.py +0 -0
- {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/managers/people_manager.py +0 -0
- {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/otel/__init__.py +0 -0
- {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/otel/helpers.py +0 -0
- {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/otel/register.py +0 -0
- {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/otel/transport.py +0 -0
- {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/prompts/__init__.py +0 -0
- {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/prompts/compile.py +0 -0
- {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/prompts/template.py +0 -0
- {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/queue/__init__.py +0 -0
- {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/queue/batch_flusher.py +0 -0
- {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/queue/event_queue.py +0 -0
- {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/session/__init__.py +0 -0
- {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/session/server_session.py +0 -0
- {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/session/session_manager.py +0 -0
- {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/types.py +0 -0
- {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo/user_context.py +0 -0
- {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo_python.egg-info/dependency_links.txt +0 -0
- {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo_python.egg-info/requires.txt +0 -0
- {trodo_python-2.13.0 → trodo_python-2.15.0}/trodo_python.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Prompt -> span traceability (mirrors the Node promptTrace.test.ts).
|
|
2
|
+
|
|
3
|
+
Compiling a managed prompt inside a wrap_agent/span scope must stamp the exact
|
|
4
|
+
version (by immutable hash) onto the emitted span, and aggregate every prompt
|
|
5
|
+
used onto the run. Outside a run, it's a silent no-op.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from trodo.managers.prompt_manager import PromptManager
|
|
10
|
+
from trodo.otel.wrap_agent import span, wrap_agent
|
|
11
|
+
|
|
12
|
+
PROMPT_WIRE = {
|
|
13
|
+
"name": "refund-agent",
|
|
14
|
+
"version": 3,
|
|
15
|
+
"version_hash": "a3f9c2e8d1b04756",
|
|
16
|
+
"content_hash": "ccccontent1234",
|
|
17
|
+
"parent_hash": "pppparent5678",
|
|
18
|
+
"labels": ["production"],
|
|
19
|
+
"tags": [],
|
|
20
|
+
"messages": [{"role": "system", "content": [{"type": "text", "text": "Help {{q}}"}]}],
|
|
21
|
+
"model": {"provider": "openai", "model": "gpt-4o"},
|
|
22
|
+
"tools": [],
|
|
23
|
+
"response_format": None,
|
|
24
|
+
"variables": [{"name": "q", "type": "string"}],
|
|
25
|
+
"updated_at": "2026-07-01T00:00:00Z",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class PromptHttp:
|
|
30
|
+
def __init__(self, prompt=None, fail=False):
|
|
31
|
+
self._prompt = prompt
|
|
32
|
+
self.fail = fail
|
|
33
|
+
|
|
34
|
+
def get_prompt(self, name, label=None, version=None):
|
|
35
|
+
if self.fail:
|
|
36
|
+
raise RuntimeError("unreachable")
|
|
37
|
+
return {"prompt": dict(self._prompt)}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_stamps_version_hash_on_span_and_aggregates_on_run(processor, http):
|
|
41
|
+
mgr = PromptManager(PromptHttp(prompt=PROMPT_WIRE))
|
|
42
|
+
with wrap_agent(processor=processor, team_site_id="site-x", agent_name="chat"):
|
|
43
|
+
p = mgr.get("refund-agent", cache_ttl_seconds=0)
|
|
44
|
+
with span("llm", kind="llm"):
|
|
45
|
+
p.compile(q="my order")
|
|
46
|
+
|
|
47
|
+
ingest = http.run_ingest[0]
|
|
48
|
+
spans = ingest["spans"]
|
|
49
|
+
llm = next(s for s in spans if s["name"] == "llm")
|
|
50
|
+
attrs = llm["attributes"]
|
|
51
|
+
assert attrs["trodo.prompt.name"] == "refund-agent"
|
|
52
|
+
assert attrs["trodo.prompt.version_hash"] == "a3f9c2e8d1b04756"
|
|
53
|
+
assert attrs["trodo.prompt.label"] == "production" # default selector
|
|
54
|
+
assert attrs["trodo.prompt.content_hash"] == "ccccontent1234"
|
|
55
|
+
|
|
56
|
+
used = ingest["run"]["attributes"]["trodo.prompts"]
|
|
57
|
+
assert len(used) == 1
|
|
58
|
+
assert used[0]["version_hash"] == "a3f9c2e8d1b04756"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_records_label_and_omits_it_for_version_pin(processor, http):
|
|
62
|
+
mgr = PromptManager(PromptHttp(prompt=PROMPT_WIRE))
|
|
63
|
+
with wrap_agent(processor=processor, team_site_id="site-x", agent_name="chat"):
|
|
64
|
+
staged = mgr.get("refund-agent", label="staging", cache_ttl_seconds=0)
|
|
65
|
+
with span("s1"):
|
|
66
|
+
staged.compile(q="x")
|
|
67
|
+
pinned = mgr.get("refund-agent", version="a3f9c2", cache_ttl_seconds=0)
|
|
68
|
+
with span("s2"):
|
|
69
|
+
pinned.compile(q="y")
|
|
70
|
+
|
|
71
|
+
spans = http.run_ingest[0]["spans"]
|
|
72
|
+
s1 = next(s for s in spans if s["name"] == "s1")["attributes"]
|
|
73
|
+
s2 = next(s for s in spans if s["name"] == "s2")["attributes"]
|
|
74
|
+
assert s1["trodo.prompt.label"] == "staging"
|
|
75
|
+
assert "trodo.prompt.label" not in s2 # pinned by hash -> no label
|
|
76
|
+
assert s2["trodo.prompt.version_hash"] == "a3f9c2e8d1b04756"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_no_op_outside_a_run(processor, http):
|
|
80
|
+
mgr = PromptManager(PromptHttp(prompt=PROMPT_WIRE))
|
|
81
|
+
p = mgr.get("refund-agent", cache_ttl_seconds=0)
|
|
82
|
+
# Must not raise despite there being no active run context.
|
|
83
|
+
p.compile(q="z")
|
|
84
|
+
assert len(http.run_ingest) == 0
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_fallback_prompt_is_not_traced(processor, http):
|
|
88
|
+
mgr = PromptManager(PromptHttp(fail=True))
|
|
89
|
+
with wrap_agent(processor=processor, team_site_id="site-x", agent_name="chat"):
|
|
90
|
+
p = mgr.get(
|
|
91
|
+
"refund-agent",
|
|
92
|
+
cache_ttl_seconds=0,
|
|
93
|
+
max_retries=0,
|
|
94
|
+
fallback={
|
|
95
|
+
"messages": [{"role": "user", "content": [{"type": "text", "text": "hi {{q}}"}]}],
|
|
96
|
+
"variables": [{"name": "q"}],
|
|
97
|
+
},
|
|
98
|
+
)
|
|
99
|
+
assert p.is_fallback is True
|
|
100
|
+
with span("llm"):
|
|
101
|
+
p.compile(q="x")
|
|
102
|
+
|
|
103
|
+
spans = http.run_ingest[0]["spans"]
|
|
104
|
+
attrs = next(s for s in spans if s["name"] == "llm").get("attributes") or {}
|
|
105
|
+
assert "trodo.prompt.name" not in attrs
|
|
106
|
+
# to_dict() drops None, so an untraced run has no 'attributes' key at all.
|
|
107
|
+
assert "attributes" not in http.run_ingest[0]["run"]
|
|
@@ -16,7 +16,7 @@ from __future__ import annotations
|
|
|
16
16
|
|
|
17
17
|
import threading
|
|
18
18
|
import time
|
|
19
|
-
from typing import Any, Callable, Dict, List, Optional, Set
|
|
19
|
+
from typing import Any, Callable, Dict, List, Optional, Set, Union
|
|
20
20
|
|
|
21
21
|
from ..prompts.compile import CompileError, compile_prompt
|
|
22
22
|
from ..prompts.template import TemplateError, render
|
|
@@ -38,7 +38,7 @@ __all__ = [
|
|
|
38
38
|
DEFAULT_TTL_SECONDS = 60.0
|
|
39
39
|
|
|
40
40
|
|
|
41
|
-
def _cache_key(name: str, version: Optional[int], label: Optional[str]) -> str:
|
|
41
|
+
def _cache_key(name: str, version: Optional[Union[int, str]], label: Optional[str]) -> str:
|
|
42
42
|
# Resolution happens server-side on every fetch; the client only caches
|
|
43
43
|
# under whatever selector was asked for. So a label flip propagates within
|
|
44
44
|
# one TTL without the client knowing anything about labels.
|
|
@@ -67,12 +67,19 @@ def _to_variables(raw: Any) -> List[PromptVariable]:
|
|
|
67
67
|
return out
|
|
68
68
|
|
|
69
69
|
|
|
70
|
-
def _to_prompt(
|
|
70
|
+
def _to_prompt(
|
|
71
|
+
raw: Dict[str, Any],
|
|
72
|
+
is_fallback: bool = False,
|
|
73
|
+
trace_label: Optional[str] = None,
|
|
74
|
+
) -> ManagedPrompt:
|
|
71
75
|
return ManagedPrompt(
|
|
72
76
|
name=str(raw.get("name") or ""),
|
|
73
77
|
description=raw.get("description"),
|
|
74
78
|
tags=list(raw.get("tags") or []),
|
|
75
79
|
version=int(raw.get("version") or 0),
|
|
80
|
+
version_hash=raw.get("version_hash"),
|
|
81
|
+
content_hash=raw.get("content_hash"),
|
|
82
|
+
parent_hash=raw.get("parent_hash"),
|
|
76
83
|
labels=list(raw.get("labels") or []),
|
|
77
84
|
messages=list(raw.get("messages") or []),
|
|
78
85
|
model=dict(raw.get("model") or {}),
|
|
@@ -81,6 +88,7 @@ def _to_prompt(raw: Dict[str, Any], is_fallback: bool = False) -> ManagedPrompt:
|
|
|
81
88
|
variables=_to_variables(raw.get("variables")),
|
|
82
89
|
updated_at=raw.get("updated_at") or raw.get("updatedAt"),
|
|
83
90
|
is_fallback=is_fallback,
|
|
91
|
+
trace_label=trace_label,
|
|
84
92
|
)
|
|
85
93
|
|
|
86
94
|
|
|
@@ -172,7 +180,7 @@ class PromptManager:
|
|
|
172
180
|
self,
|
|
173
181
|
name: str,
|
|
174
182
|
label: Optional[str] = None,
|
|
175
|
-
version: Optional[int] = None,
|
|
183
|
+
version: Optional[Union[int, str]] = None,
|
|
176
184
|
cache_ttl_seconds: Optional[float] = None,
|
|
177
185
|
fallback: Optional[Dict[str, Any]] = None,
|
|
178
186
|
max_retries: int = 2,
|
|
@@ -181,7 +189,8 @@ class PromptManager:
|
|
|
181
189
|
|
|
182
190
|
Without arguments you get the version labelled ``production``; pass
|
|
183
191
|
``label`` to follow a different deploy label, or ``version`` to pin
|
|
184
|
-
exactly.
|
|
192
|
+
exactly. ``version`` may be the integer ``version_no`` or a
|
|
193
|
+
``version_hash`` (full, or an unambiguous short prefix like ``a3f9c2``).
|
|
185
194
|
|
|
186
195
|
Availability ladder — fresh cache -> stale cache -> ``fallback`` ->
|
|
187
196
|
raise. A prompt fetch is on your hot path, so a Trodo outage degrades
|
|
@@ -200,6 +209,10 @@ class PromptManager:
|
|
|
200
209
|
|
|
201
210
|
key = _cache_key(name, version, label)
|
|
202
211
|
ttl = DEFAULT_TTL_SECONDS if cache_ttl_seconds is None else float(cache_ttl_seconds)
|
|
212
|
+
# The deploy label this fetch followed, for prompt traceability. Explicit
|
|
213
|
+
# label wins; with neither label nor version pinned the SDK follows
|
|
214
|
+
# ``production``; a version pin has no label (the hash is the identity).
|
|
215
|
+
trace_label = label if label else ("production" if version is None else None)
|
|
203
216
|
|
|
204
217
|
def fetcher() -> Dict[str, Any]:
|
|
205
218
|
res = _with_retry(
|
|
@@ -219,24 +232,24 @@ class PromptManager:
|
|
|
219
232
|
if ttl > 0:
|
|
220
233
|
fresh = self._cache.get_fresh(key)
|
|
221
234
|
if fresh is not None:
|
|
222
|
-
return _to_prompt(fresh)
|
|
235
|
+
return _to_prompt(fresh, trace_label=trace_label)
|
|
223
236
|
|
|
224
237
|
stale = self._cache.get_stale(key)
|
|
225
238
|
if stale is not None:
|
|
226
239
|
# Serve immediately, refresh behind the caller's back. A slow or
|
|
227
240
|
# dead API costs latency on nobody's request.
|
|
228
241
|
self._cache.revalidate(key, fetcher, ttl)
|
|
229
|
-
return _to_prompt(stale)
|
|
242
|
+
return _to_prompt(stale, trace_label=trace_label)
|
|
230
243
|
|
|
231
244
|
try:
|
|
232
245
|
raw = fetcher()
|
|
233
246
|
if ttl > 0:
|
|
234
247
|
self._cache.set(key, raw, ttl)
|
|
235
|
-
return _to_prompt(raw)
|
|
248
|
+
return _to_prompt(raw, trace_label=trace_label)
|
|
236
249
|
except Exception:
|
|
237
250
|
stale = self._cache.get_stale(key)
|
|
238
251
|
if stale is not None:
|
|
239
|
-
return _to_prompt(stale)
|
|
252
|
+
return _to_prompt(stale, trace_label=trace_label)
|
|
240
253
|
if fallback:
|
|
241
254
|
return _to_prompt(
|
|
242
255
|
{
|
|
@@ -14,6 +14,7 @@ from datetime import datetime, timezone
|
|
|
14
14
|
from typing import Any, Callable, Dict, Iterable, List, Optional
|
|
15
15
|
|
|
16
16
|
from .context import get_active_context
|
|
17
|
+
from .prompt_trace import prompt_attributes
|
|
17
18
|
from .processor import TrodoSpan, TrodoSpanProcessor
|
|
18
19
|
|
|
19
20
|
# Always-on, one-shot warnings. Silent skips (missing opentelemetry-sdk) are the
|
|
@@ -376,6 +377,14 @@ class _OtelAdapter(_SpanProcessorBase): # type: ignore[valid-type,misc]
|
|
|
376
377
|
set_attr(_ATTR_TRODO_RUN_ID, ctx.run_id)
|
|
377
378
|
if ctx.span_id:
|
|
378
379
|
set_attr(_ATTR_TRODO_PARENT_SPAN_ID, ctx.span_id)
|
|
380
|
+
# Prompt traceability: stamp the exact managed-prompt version
|
|
381
|
+
# compiled just before this (auto-instrumented) provider call.
|
|
382
|
+
# Pinned by immutable version_hash, so a later label move never
|
|
383
|
+
# rewrites what a past span ran.
|
|
384
|
+
state = getattr(ctx, "prompt_state", None)
|
|
385
|
+
current = state.get("current") if isinstance(state, dict) else None
|
|
386
|
+
for k, v in prompt_attributes(current).items():
|
|
387
|
+
set_attr(k, v)
|
|
379
388
|
except Exception:
|
|
380
389
|
pass # never break user code
|
|
381
390
|
|
|
@@ -16,6 +16,10 @@ class ActiveSpanContext:
|
|
|
16
16
|
parent_span_id: Optional[str]
|
|
17
17
|
team_site_id: str
|
|
18
18
|
processor: object # TrodoSpanProcessor — avoid circular import
|
|
19
|
+
# Per-run prompt-traceability accumulator ({"current", "all"}), shared by
|
|
20
|
+
# reference across the run's whole span tree. Records which managed prompt
|
|
21
|
+
# version each span used. See otel/prompt_trace.py.
|
|
22
|
+
prompt_state: Optional[dict] = None
|
|
19
23
|
|
|
20
24
|
|
|
21
25
|
_active: contextvars.ContextVar[Optional[ActiveSpanContext]] = contextvars.ContextVar(
|
|
@@ -33,6 +33,9 @@ class TrodoRun:
|
|
|
33
33
|
# free-text error_summary).
|
|
34
34
|
error_type: Optional[str] = None
|
|
35
35
|
metadata: Optional[Dict[str, Any]] = None
|
|
36
|
+
# Free-form run-level attributes (e.g. {"trodo.prompts": [...]} — the set of
|
|
37
|
+
# managed-prompt versions used across the run, for prompt traceability).
|
|
38
|
+
attributes: Optional[Dict[str, Any]] = None
|
|
36
39
|
# Aggregates summed from child spans at finalisation.
|
|
37
40
|
total_tokens_in: Optional[int] = None
|
|
38
41
|
total_tokens_out: Optional[int] = None
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Prompt -> span traceability.
|
|
2
|
+
|
|
3
|
+
When a managed prompt is compiled inside a ``wrap_agent`` / ``span`` scope, we
|
|
4
|
+
record which exact version was used so every span emitted in that scope carries
|
|
5
|
+
it. The identity is the immutable ``version_hash`` — so a span always shows the
|
|
6
|
+
exact version that ran, even if a deploy label (e.g. ``production``) is later
|
|
7
|
+
moved to a different version.
|
|
8
|
+
|
|
9
|
+
The link is best-effort and never raises: if there is no active run context
|
|
10
|
+
(the prompt was compiled outside ``wrap_agent``), recording is a silent no-op.
|
|
11
|
+
|
|
12
|
+
Mirrors ``sdks/trodo-node-sdk/src/otel/promptTrace.ts``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Any, Dict, List, Optional
|
|
18
|
+
|
|
19
|
+
from .context import get_active_context
|
|
20
|
+
|
|
21
|
+
# A PromptRef is a plain dict: {name, version_hash?, content_hash?, label?}.
|
|
22
|
+
PromptRef = Dict[str, Any]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def new_prompt_state() -> Dict[str, Any]:
|
|
26
|
+
"""Per-run accumulator, shared by reference across the run's span tree."""
|
|
27
|
+
return {"current": None, "all": []}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _ref_key(ref: PromptRef) -> str:
|
|
31
|
+
return ref.get("version_hash") or ref.get("name") or ""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def record_compiled_prompt(ref: PromptRef) -> None:
|
|
35
|
+
"""Record that a prompt was just compiled.
|
|
36
|
+
|
|
37
|
+
Updates the active run's prompt state (most-recent + the deduped set) and,
|
|
38
|
+
in OTLP mode where a real OpenTelemetry span is current, stamps that span.
|
|
39
|
+
"""
|
|
40
|
+
if not ref or not ref.get("name"):
|
|
41
|
+
return
|
|
42
|
+
active = get_active_context()
|
|
43
|
+
state = getattr(active, "prompt_state", None) if active is not None else None
|
|
44
|
+
if isinstance(state, dict):
|
|
45
|
+
state["current"] = ref
|
|
46
|
+
key = _ref_key(ref)
|
|
47
|
+
used: List[PromptRef] = state["all"]
|
|
48
|
+
if not any(_ref_key(r) == key for r in used):
|
|
49
|
+
used.append(ref)
|
|
50
|
+
_stamp_active_otel_span(ref)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def prompt_attributes(ref: Optional[PromptRef]) -> Dict[str, str]:
|
|
54
|
+
"""Flatten a PromptRef to primitive span attributes (None values omitted)."""
|
|
55
|
+
out: Dict[str, str] = {}
|
|
56
|
+
if not ref or not ref.get("name"):
|
|
57
|
+
return out
|
|
58
|
+
out["trodo.prompt.name"] = ref["name"]
|
|
59
|
+
if ref.get("version_hash"):
|
|
60
|
+
out["trodo.prompt.version_hash"] = ref["version_hash"]
|
|
61
|
+
if ref.get("label"):
|
|
62
|
+
out["trodo.prompt.label"] = ref["label"]
|
|
63
|
+
if ref.get("content_hash"):
|
|
64
|
+
out["trodo.prompt.content_hash"] = ref["content_hash"]
|
|
65
|
+
return out
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def merge_prompt_attrs(
|
|
69
|
+
active: Any, attrs: Optional[Dict[str, Any]]
|
|
70
|
+
) -> Optional[Dict[str, Any]]:
|
|
71
|
+
"""Merge the active run's most-recently-compiled prompt into span attributes."""
|
|
72
|
+
merged: Dict[str, Any] = dict(attrs or {})
|
|
73
|
+
state = getattr(active, "prompt_state", None) if active is not None else None
|
|
74
|
+
if isinstance(state, dict):
|
|
75
|
+
merged.update(prompt_attributes(state.get("current")))
|
|
76
|
+
return merged or None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _stamp_active_otel_span(ref: PromptRef) -> None:
|
|
80
|
+
"""OTLP mode only: stamp the current real OTel span, if one exists.
|
|
81
|
+
|
|
82
|
+
Wrapped in try/except — ``opentelemetry`` is an optional dependency present
|
|
83
|
+
only when the user runs in OTLP mode.
|
|
84
|
+
"""
|
|
85
|
+
try:
|
|
86
|
+
from opentelemetry import trace # type: ignore
|
|
87
|
+
|
|
88
|
+
span = trace.get_current_span()
|
|
89
|
+
if span is None:
|
|
90
|
+
return
|
|
91
|
+
for k, v in prompt_attributes(ref).items():
|
|
92
|
+
span.set_attribute(k, v)
|
|
93
|
+
except Exception: # noqa: BLE001
|
|
94
|
+
pass # not in OTLP mode / opentelemetry not installed — no-op
|
|
@@ -32,6 +32,7 @@ from datetime import datetime, timezone
|
|
|
32
32
|
from typing import Any, Callable, Dict, List, Optional, Union
|
|
33
33
|
|
|
34
34
|
from .context import ActiveSpanContext, get_active_context, run_with_context
|
|
35
|
+
from .prompt_trace import merge_prompt_attrs, new_prompt_state
|
|
35
36
|
from .processor import TrodoSpanProcessor, TrodoRun, TrodoSpan
|
|
36
37
|
from .transport import get_transport_mode, get_otel_tracer, get_otel_helpers
|
|
37
38
|
|
|
@@ -515,6 +516,7 @@ class wrap_agent:
|
|
|
515
516
|
self._parent_run_id = parent_run_id
|
|
516
517
|
self._metadata = metadata
|
|
517
518
|
self._ctx_mgr: Optional[run_with_context] = None
|
|
519
|
+
self._ctx: Optional[ActiveSpanContext] = None
|
|
518
520
|
self._started_ms: float = 0.0
|
|
519
521
|
self._started_iso: str = ""
|
|
520
522
|
self.handle: Optional[RunHandle] = None
|
|
@@ -551,7 +553,9 @@ class wrap_agent:
|
|
|
551
553
|
parent_span_id=None,
|
|
552
554
|
team_site_id=self._team_site_id,
|
|
553
555
|
processor=self._processor,
|
|
556
|
+
prompt_state=new_prompt_state(),
|
|
554
557
|
)
|
|
558
|
+
self._ctx = ctx
|
|
555
559
|
self._ctx_mgr = run_with_context(ctx)
|
|
556
560
|
self._ctx_mgr.__enter__()
|
|
557
561
|
return self.handle
|
|
@@ -596,6 +600,13 @@ class wrap_agent:
|
|
|
596
600
|
error_summary=error_summary,
|
|
597
601
|
error_type=error_type,
|
|
598
602
|
metadata={**(self._metadata or {}), **self.handle.metadata} or None,
|
|
603
|
+
attributes=(
|
|
604
|
+
{"trodo.prompts": self._ctx.prompt_state["all"]}
|
|
605
|
+
if self._ctx is not None
|
|
606
|
+
and self._ctx.prompt_state
|
|
607
|
+
and self._ctx.prompt_state["all"]
|
|
608
|
+
else None
|
|
609
|
+
),
|
|
599
610
|
total_tokens_in=agg["total_tokens_in"],
|
|
600
611
|
total_tokens_out=agg["total_tokens_out"],
|
|
601
612
|
total_cost=agg["total_cost"],
|
|
@@ -709,6 +720,7 @@ class join_run:
|
|
|
709
720
|
self._input = _prepare_value(input) if input is not None else None
|
|
710
721
|
self._attributes = attributes
|
|
711
722
|
self._ctx_mgr: Optional[run_with_context] = None
|
|
723
|
+
self._ctx: Optional[ActiveSpanContext] = None
|
|
712
724
|
self._started_ms: float = 0.0
|
|
713
725
|
self._started_iso: str = ""
|
|
714
726
|
self._span_id: str = ""
|
|
@@ -731,7 +743,9 @@ class join_run:
|
|
|
731
743
|
parent_span_id=self._parent_span_id,
|
|
732
744
|
team_site_id=self._team_site_id,
|
|
733
745
|
processor=self._processor,
|
|
746
|
+
prompt_state=new_prompt_state(),
|
|
734
747
|
)
|
|
748
|
+
self._ctx = ctx
|
|
735
749
|
self._ctx_mgr = run_with_context(ctx)
|
|
736
750
|
self._ctx_mgr.__enter__()
|
|
737
751
|
return self.handle
|
|
@@ -771,7 +785,7 @@ class join_run:
|
|
|
771
785
|
cost_details=self.handle.cost_details,
|
|
772
786
|
temperature=self.handle.temperature,
|
|
773
787
|
tool_name=self.handle.tool_name,
|
|
774
|
-
attributes=self.handle.attributes
|
|
788
|
+
attributes=merge_prompt_attrs(self._ctx, self.handle.attributes),
|
|
775
789
|
)
|
|
776
790
|
try:
|
|
777
791
|
self._processor.append_spans(self._run_id, [trodo_span])
|
|
@@ -833,6 +847,7 @@ class span:
|
|
|
833
847
|
parent_span_id=self._active.span_id,
|
|
834
848
|
team_site_id=self._active.team_site_id,
|
|
835
849
|
processor=self._active.processor,
|
|
850
|
+
prompt_state=self._active.prompt_state,
|
|
836
851
|
)
|
|
837
852
|
self._ctx_mgr = run_with_context(child)
|
|
838
853
|
self._ctx_mgr.__enter__()
|
|
@@ -877,7 +892,7 @@ class span:
|
|
|
877
892
|
cost_details=self.handle.cost_details,
|
|
878
893
|
temperature=self.handle.temperature,
|
|
879
894
|
tool_name=self.handle.tool_name,
|
|
880
|
-
attributes=self.handle.attributes
|
|
895
|
+
attributes=merge_prompt_attrs(self._active, self.handle.attributes),
|
|
881
896
|
)
|
|
882
897
|
processor: TrodoSpanProcessor = self._active.processor # type: ignore[assignment]
|
|
883
898
|
processor.enqueue_span(trodo_span)
|
|
@@ -59,6 +59,13 @@ class ManagedPrompt:
|
|
|
59
59
|
response_format: Optional[ResponseFormat] = None
|
|
60
60
|
variables: List[PromptVariable] = field(default_factory=list)
|
|
61
61
|
version: int = 0
|
|
62
|
+
#: Git-style commit id of this version (content + parent + message + author
|
|
63
|
+
#: + time). Stable and immutable — the recommended way to reference a version.
|
|
64
|
+
version_hash: Optional[str] = None
|
|
65
|
+
#: sha256 of the content; identical content shares it.
|
|
66
|
+
content_hash: Optional[str] = None
|
|
67
|
+
#: The preceding version's ``version_hash`` (None for the first).
|
|
68
|
+
parent_hash: Optional[str] = None
|
|
62
69
|
labels: List[str] = field(default_factory=list)
|
|
63
70
|
tags: List[str] = field(default_factory=list)
|
|
64
71
|
description: Optional[str] = None
|
|
@@ -66,6 +73,10 @@ class ManagedPrompt:
|
|
|
66
73
|
#: True when this came from the ``fallback`` argument because the API was
|
|
67
74
|
#: unreachable and nothing was cached.
|
|
68
75
|
is_fallback: bool = False
|
|
76
|
+
#: The deploy label this fetch followed (for prompt traceability). Set by
|
|
77
|
+
#: PromptManager.get(); not part of the wire contract. ``repr=False`` /
|
|
78
|
+
#: ``compare=False`` so it never affects equality or reprs.
|
|
79
|
+
trace_label: Optional[str] = field(default=None, repr=False, compare=False)
|
|
69
80
|
|
|
70
81
|
def compile(self, variables: Optional[Dict[str, Any]] = None, **kwargs: Any) -> CompiledPrompt:
|
|
71
82
|
"""Compile with variable values, as a dict or as keyword arguments."""
|
|
@@ -73,7 +84,25 @@ class ManagedPrompt:
|
|
|
73
84
|
|
|
74
85
|
values = dict(variables or {})
|
|
75
86
|
values.update(kwargs)
|
|
76
|
-
|
|
87
|
+
compiled = compile_prompt(self, values)
|
|
88
|
+
# Prompt traceability: record the exact version compiled so any span
|
|
89
|
+
# emitted in the surrounding wrap_agent/span scope carries it. No-op
|
|
90
|
+
# outside a run context. Never records a fallback (no real hash).
|
|
91
|
+
if not self.is_fallback:
|
|
92
|
+
try:
|
|
93
|
+
from ..otel.prompt_trace import record_compiled_prompt
|
|
94
|
+
|
|
95
|
+
record_compiled_prompt(
|
|
96
|
+
{
|
|
97
|
+
"name": self.name,
|
|
98
|
+
"version_hash": self.version_hash,
|
|
99
|
+
"content_hash": self.content_hash,
|
|
100
|
+
"label": self.trace_label,
|
|
101
|
+
}
|
|
102
|
+
)
|
|
103
|
+
except Exception: # noqa: BLE001
|
|
104
|
+
pass # traceability must never break compile
|
|
105
|
+
return compiled
|
|
77
106
|
|
|
78
107
|
|
|
79
108
|
@dataclass
|
|
@@ -7,6 +7,7 @@ tests/test_end_run.py
|
|
|
7
7
|
tests/test_error_enrichment.py
|
|
8
8
|
tests/test_llm_usage_cost.py
|
|
9
9
|
tests/test_processor_methods.py
|
|
10
|
+
tests/test_prompt_trace.py
|
|
10
11
|
tests/test_prompts.py
|
|
11
12
|
tests/test_register_otel.py
|
|
12
13
|
tests/test_start_run.py
|
|
@@ -30,6 +31,7 @@ trodo/otel/auto_instrument.py
|
|
|
30
31
|
trodo/otel/context.py
|
|
31
32
|
trodo/otel/helpers.py
|
|
32
33
|
trodo/otel/processor.py
|
|
34
|
+
trodo/otel/prompt_trace.py
|
|
33
35
|
trodo/otel/register.py
|
|
34
36
|
trodo/otel/transport.py
|
|
35
37
|
trodo/otel/wrap_agent.py
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|