trodo-python 2.20.0__py3-none-any.whl → 2.22.0__py3-none-any.whl

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/__init__.py CHANGED
@@ -41,7 +41,7 @@ Downstream microservice (join the caller's run instead of making a new one):
41
41
 
42
42
  from __future__ import annotations
43
43
 
44
- __version__ = "2.20.0"
44
+ __version__ = "2.21.0"
45
45
 
46
46
  from typing import Any, Callable, Dict, List, Optional, Union
47
47
 
@@ -38,6 +38,11 @@ __all__ = [
38
38
  DEFAULT_TTL_SECONDS = 60.0
39
39
 
40
40
 
41
+ # 404 bodies that mean "your SELECTOR is wrong", not "the prompt is gone".
42
+ # Mirrors backend/models/prompt.js missReason -- the wire contract's error half.
43
+ _CONFIG_ERROR_CODES = frozenset({"version_not_found", "label_not_found", "no_versions"})
44
+
45
+
41
46
  def _cache_key(name: str, version: Optional[Union[int, str]], label: Optional[str]) -> str:
42
47
  # Resolution happens server-side on every fetch; the client only caches
43
48
  # under whatever selector was asked for. So a label flip propagates within
@@ -197,7 +202,14 @@ class PromptManager:
197
202
  Availability ladder — fresh cache -> stale cache -> ``fallback`` ->
198
203
  raise. A prompt fetch is on your hot path, so a Trodo outage degrades
199
204
  rather than takes your app down. Check ``prompt.is_fallback`` to detect
200
- the last rung. Pass ``cache_ttl_seconds=0`` to disable caching (handy in
205
+ the last rung.
206
+
207
+ The ladder is for AVAILABILITY failures only. A selector that names
208
+ nothing — a ``version`` or ``label`` that doesn't exist on a prompt
209
+ that does — is a config error in your code and raises immediately
210
+ (``e.code`` is ``version_not_found`` | ``label_not_found`` |
211
+ ``no_versions``), because being quietly handed the fallback would hide
212
+ the typo for as long as it ships. Pass ``cache_ttl_seconds=0`` to disable caching (handy in
201
213
  development).
202
214
 
203
215
  :raises ValueError: if *name* is empty, or both ``label`` and ``version``
@@ -224,6 +236,22 @@ class PromptManager:
224
236
  if not res or res.get("__error") or not res.get("prompt"):
225
237
  status = res.get("status") if isinstance(res, dict) else None
226
238
  detail = res.get("error") if isinstance(res, dict) else None
239
+ # The server distinguishes a selector that names nothing from a
240
+ # prompt that is missing. The first is a CONFIG error -- your
241
+ # code asks for a version or label that does not exist -- and
242
+ # must throw through the availability ladder below rather than
243
+ # be masked by stale content or the fallback.
244
+ if detail in _CONFIG_ERROR_CODES:
245
+ if detail == "version_not_found":
246
+ what = f"version {version!r} does not exist on prompt {name!r}"
247
+ elif detail == "label_not_found":
248
+ what = f"label {label!r} does not exist on prompt {name!r}"
249
+ else:
250
+ what = f"prompt {name!r} has no versions yet -- save one in the dashboard"
251
+ err = LookupError(f"trodo: {what}" + (f" (HTTP {status})" if status else ""))
252
+ err.code = detail # type: ignore[attr-defined]
253
+ err.prompt_config_error = True # type: ignore[attr-defined]
254
+ raise err
227
255
  raise LookupError(
228
256
  f"trodo: could not fetch prompt {name!r}"
229
257
  + (f" (HTTP {status})" if status else "")
@@ -248,7 +276,12 @@ class PromptManager:
248
276
  if ttl > 0:
249
277
  self._cache.set(key, raw, ttl)
250
278
  return _to_prompt(raw, trace_label=trace_label)
251
- except Exception:
279
+ except Exception as e:
280
+ # A config error is not an outage: a typo'd label silently serving
281
+ # the fallback forever would hide the mistake for as long as it
282
+ # ships. Config errors surface.
283
+ if getattr(e, "prompt_config_error", False):
284
+ raise
252
285
  stale = self._cache.get_stale(key)
253
286
  if stale is not None:
254
287
  return _to_prompt(stale, trace_label=trace_label)
@@ -401,6 +401,46 @@ class _OtelAdapter(_SpanProcessorBase): # type: ignore[valid-type,misc]
401
401
  return True
402
402
 
403
403
 
404
+ def _instrument(module_name: str, *preferred: str) -> None:
405
+ """Import ``module_name`` and call ``instrument()`` on its Instrumentor.
406
+
407
+ Upstream renames the exported class often enough that pinning one exact
408
+ spelling is a silent-failure generator: the import raises, the caller's
409
+ ``except Exception`` treats it as "package not installed", and the user who
410
+ installed exactly what we told them to gets no spans and no error. That is
411
+ how ``LangChainInstrumentor`` (upstream ships ``LangchainInstrumentor``) and
412
+ ``GoogleGenerativeAIInstrumentor`` both went dark.
413
+
414
+ So: try the known spellings in order, then fall back to whatever single
415
+ ``*Instrumentor`` the module exports. Mirrors ``instrCtor`` in the Node SDK.
416
+ Raises ImportError if nothing usable is found, which the caller reports.
417
+ """
418
+ import importlib
419
+
420
+ mod = importlib.import_module(module_name)
421
+ cls = None
422
+ for name in preferred:
423
+ cls = getattr(mod, name, None)
424
+ if cls is not None:
425
+ break
426
+ if cls is None:
427
+ found = [
428
+ n for n in dir(mod)
429
+ if n.endswith("Instrumentor") and n != "BaseInstrumentor"
430
+ and isinstance(getattr(mod, n, None), type)
431
+ ]
432
+ if len(found) == 1:
433
+ cls = getattr(mod, found[0])
434
+ elif found:
435
+ raise ImportError(
436
+ f"{module_name} exports several Instrumentor classes {found}; "
437
+ f"none matched the expected names {list(preferred)}"
438
+ )
439
+ if cls is None:
440
+ raise ImportError(f"no Instrumentor class exported by {module_name}")
441
+ cls().instrument()
442
+
443
+
404
444
  _INSTRUMENTORS: List[tuple[str, Callable[[], Any]]] = []
405
445
 
406
446
 
@@ -416,84 +456,49 @@ def _register_instrumentors() -> None:
416
456
  return
417
457
 
418
458
  def _anthropic() -> Any:
419
- from opentelemetry.instrumentation.anthropic import AnthropicInstrumentor # type: ignore
420
-
421
- AnthropicInstrumentor().instrument()
459
+ _instrument("opentelemetry.instrumentation.anthropic", "AnthropicInstrumentor")
422
460
 
423
461
  def _openai() -> Any:
424
- from opentelemetry.instrumentation.openai import OpenAIInstrumentor # type: ignore
425
-
426
- OpenAIInstrumentor().instrument()
462
+ _instrument("opentelemetry.instrumentation.openai", "OpenAIInstrumentor")
427
463
 
428
464
  def _openai_v2() -> Any:
429
- from opentelemetry.instrumentation.openai_v2 import OpenAIInstrumentor # type: ignore
430
-
431
- OpenAIInstrumentor().instrument()
465
+ _instrument("opentelemetry.instrumentation.openai_v2", "OpenAIInstrumentor")
432
466
 
433
467
  def _langchain() -> Any:
434
- from opentelemetry.instrumentation.langchain import LangChainInstrumentor # type: ignore
435
-
436
- LangChainInstrumentor().instrument()
468
+ _instrument("opentelemetry.instrumentation.langchain", "LangchainInstrumentor", "LangChainInstrumentor")
437
469
 
438
470
  def _llama_index() -> Any:
439
- from opentelemetry.instrumentation.llama_index import LlamaIndexInstrumentor # type: ignore
440
-
441
- LlamaIndexInstrumentor().instrument()
471
+ _instrument("opentelemetry.instrumentation.llama_index", "LlamaIndexInstrumentor")
442
472
 
443
473
  def _google_generativeai() -> Any:
444
- import opentelemetry.instrumentation.google_generativeai as _m # type: ignore
445
-
446
- # The exported class name varies by version: GoogleGenerativeAiInstrumentor
447
- # (current — note the lowercase 'i'), GoogleGenerativeAIInstrumentor, or
448
- # GenAIInstrumentor. Importing the wrong casing silently disabled Gemini
449
- # auto-instrumentation. (This instrumentation patches the new @google/genai
450
- # SDK, i.e. the `google-genai` package, not legacy google-generativeai.)
451
- instr = (
452
- getattr(_m, "GoogleGenerativeAiInstrumentor", None)
453
- or getattr(_m, "GoogleGenerativeAIInstrumentor", None)
454
- or getattr(_m, "GenAIInstrumentor", None)
474
+ # Patches the new google-genai SDK, not legacy google-generativeai.
475
+ _instrument(
476
+ "opentelemetry.instrumentation.google_generativeai",
477
+ "GoogleGenerativeAiInstrumentor",
478
+ "GoogleGenerativeAIInstrumentor",
479
+ "GenAIInstrumentor",
455
480
  )
456
- if instr is None:
457
- raise ImportError(
458
- "no GoogleGenerativeAi/GenAI Instrumentor in "
459
- "opentelemetry.instrumentation.google_generativeai"
460
- )
461
- instr().instrument()
462
481
 
463
482
  def _vertexai() -> Any:
464
- from opentelemetry.instrumentation.vertexai import VertexAIInstrumentor # type: ignore
465
-
466
- VertexAIInstrumentor().instrument()
483
+ _instrument("opentelemetry.instrumentation.vertexai", "VertexAIInstrumentor")
467
484
 
468
485
  def _bedrock() -> Any:
469
- from opentelemetry.instrumentation.bedrock import BedrockInstrumentor # type: ignore
470
-
471
- BedrockInstrumentor().instrument()
486
+ _instrument("opentelemetry.instrumentation.bedrock", "BedrockInstrumentor")
472
487
 
473
488
  def _cohere() -> Any:
474
- from opentelemetry.instrumentation.cohere import CohereInstrumentor # type: ignore
475
-
476
- CohereInstrumentor().instrument()
489
+ _instrument("opentelemetry.instrumentation.cohere", "CohereInstrumentor")
477
490
 
478
491
  def _mistralai() -> Any:
479
- from opentelemetry.instrumentation.mistralai import MistralAiInstrumentor # type: ignore
480
-
481
- MistralAiInstrumentor().instrument()
492
+ _instrument("opentelemetry.instrumentation.mistralai", "MistralAiInstrumentor")
482
493
 
483
494
  def _haystack() -> Any:
484
- from opentelemetry.instrumentation.haystack import HaystackInstrumentor # type: ignore
485
-
486
- HaystackInstrumentor().instrument()
495
+ _instrument("opentelemetry.instrumentation.haystack", "HaystackInstrumentor")
487
496
 
488
497
  def _httpx() -> Any:
489
- from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor # type: ignore
490
-
491
- HTTPXClientInstrumentor().instrument()
498
+ _instrument("opentelemetry.instrumentation.httpx", "HTTPXClientInstrumentor")
492
499
 
493
500
  def _requests() -> Any:
494
- from opentelemetry.instrumentation.requests import RequestsInstrumentor # type: ignore
495
-
496
- RequestsInstrumentor().instrument()
501
+ _instrument("opentelemetry.instrumentation.requests", "RequestsInstrumentor")
497
502
 
498
503
  _INSTRUMENTORS = [
499
504
  ("anthropic", _anthropic),
@@ -14,6 +14,7 @@ Mirrors ``sdks/trodo-node-sdk/src/otel/promptTrace.ts``.
14
14
 
15
15
  from __future__ import annotations
16
16
 
17
+ from collections import OrderedDict
17
18
  from typing import Any, Dict, List, Optional
18
19
 
19
20
  from .context import get_active_context
@@ -27,6 +28,55 @@ def new_prompt_state() -> Dict[str, Any]:
27
28
  return {"current": None, "all": []}
28
29
 
29
30
 
31
+ # Compiled prompt (or its messages list) -> the version it came from.
32
+ #
33
+ # Mirrors the Node SDK. The run-scope mechanism below only fires when compile()
34
+ # runs inside a run, which leaves the ordinary "fetch and compile up front, open
35
+ # the run around the model call" shape with no link at all. Tagging the compiled
36
+ # value means a span handed those messages as ``input`` recovers the version
37
+ # wherever compile() happened.
38
+ #
39
+ # Keyed by id() with a weak-ish discipline: lists are unhashable and cannot go
40
+ # in a WeakValueDictionary, so entries are bounded and evicted FIFO rather than
41
+ # held forever. The link is best-effort — losing an old entry costs one span's
42
+ # label, never correctness.
43
+ _MAX_CARRIERS = 4096
44
+ _carrier_refs: "OrderedDict[int, PromptRef]" = OrderedDict()
45
+ _carrier_keepalive: "OrderedDict[int, Any]" = OrderedDict()
46
+
47
+
48
+ def tag_carrier_with_prompt(carrier: Any, ref: PromptRef) -> None:
49
+ """Attach a prompt identity to a value the caller will pass to a span."""
50
+ if carrier is None or isinstance(carrier, (str, bytes, int, float, bool)):
51
+ return
52
+ key = id(carrier)
53
+ _carrier_refs[key] = ref
54
+ # Hold a reference so the id cannot be recycled by another object while the
55
+ # mapping still names it — that would mislabel an unrelated span.
56
+ _carrier_keepalive[key] = carrier
57
+ _carrier_refs.move_to_end(key)
58
+ _carrier_keepalive.move_to_end(key)
59
+ while len(_carrier_refs) > _MAX_CARRIERS:
60
+ old, _ = _carrier_refs.popitem(last=False)
61
+ _carrier_keepalive.pop(old, None)
62
+
63
+
64
+ def prompt_ref_from_carrier(carrier: Any) -> Optional[PromptRef]:
65
+ """Recover the prompt identity from a span's input, if it carries one."""
66
+ if carrier is None or isinstance(carrier, (str, bytes, int, float, bool)):
67
+ return None
68
+ return _carrier_refs.get(id(carrier))
69
+
70
+
71
+ def remember_prompt(state: Any, ref: PromptRef) -> None:
72
+ """Add a ref to the run's deduped set if it isn't already there."""
73
+ if not isinstance(state, dict) or not ref or not ref.get("name"):
74
+ return
75
+ key = _ref_key(ref)
76
+ if not any(_ref_key(r) == key for r in state.get("all", [])):
77
+ state.setdefault("all", []).append(ref)
78
+
79
+
30
80
  def _ref_key(ref: PromptRef) -> str:
31
81
  return ref.get("version_hash") or ref.get("name") or ""
32
82
 
@@ -66,12 +116,24 @@ def prompt_attributes(ref: Optional[PromptRef]) -> Dict[str, str]:
66
116
 
67
117
 
68
118
  def merge_prompt_attrs(
69
- active: Any, attrs: Optional[Dict[str, Any]]
119
+ active: Any, attrs: Optional[Dict[str, Any]], from_input: Optional[PromptRef] = None
70
120
  ) -> Optional[Dict[str, Any]]:
71
- """Merge the active run's most-recently-compiled prompt into span attributes."""
121
+ """Merge the prompt this span used into its attributes.
122
+
123
+ The span's own INPUT wins over the run's most-recently-compiled prompt: it
124
+ is more specific (in a run using two prompts, a span holding prompt A's
125
+ messages must report A even if B was compiled later), and it is the only
126
+ signal available when compile() ran outside the run.
127
+
128
+ A prompt found this way is also folded into the run's deduped set so the
129
+ run-level ``trodo.prompts`` list stays complete either way.
130
+ """
72
131
  merged: Dict[str, Any] = dict(attrs or {})
73
132
  state = getattr(active, "prompt_state", None) if active is not None else None
74
- if isinstance(state, dict):
133
+ if from_input is not None:
134
+ remember_prompt(state, from_input)
135
+ merged.update(prompt_attributes(from_input))
136
+ elif isinstance(state, dict):
75
137
  merged.update(prompt_attributes(state.get("current")))
76
138
  return merged or None
77
139
 
trodo/otel/wrap_agent.py CHANGED
@@ -32,7 +32,12 @@ 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
+ from .prompt_trace import (
36
+ merge_prompt_attrs,
37
+ new_prompt_state,
38
+ prompt_ref_from_carrier,
39
+ remember_prompt,
40
+ )
36
41
  from .processor import TrodoSpanProcessor, TrodoRun, TrodoSpan
37
42
  from .transport import get_transport_mode, get_otel_tracer, get_otel_helpers
38
43
 
@@ -258,16 +263,44 @@ def _mint_anon_distinct_id() -> str:
258
263
  return f"anon_{ts}_python_{uuid.uuid4()}_{rand}"
259
264
 
260
265
 
266
+ def _tag_error_with_run(exc: object, run_id: Optional[str]) -> None:
267
+ """Stamp the active run's id onto an exception about to propagate.
268
+
269
+ The run IS recorded server-side when an agent raises -- status, error type,
270
+ full message. What was missing is the join: the exception a developer's
271
+ error tracker captures had no reference to the recorded run, so the two
272
+ could only be matched by timestamp. ``exc.trodo_run_id`` is that join.
273
+
274
+ Guarded on purpose: exceptions can use ``__slots__`` or be otherwise
275
+ unwritable, and a crash inside error handling is the one unforgivable
276
+ failure mode here.
277
+ """
278
+ if exc is None or not run_id:
279
+ return
280
+ try:
281
+ exc.trodo_run_id = run_id # type: ignore[attr-defined]
282
+ except Exception: # noqa: BLE001 -- slots/frozen; the run is still recorded
283
+ pass
284
+
285
+
261
286
  class RunHandle:
262
287
  """Handle returned by wrap_agent for setting input/output and getting run_id."""
263
288
 
264
- def __init__(self, run_id: str, agent_name: str, distinct_id: str) -> None:
289
+ def __init__(
290
+ self,
291
+ run_id: str,
292
+ agent_name: str,
293
+ distinct_id: str,
294
+ prompt_state: Optional[Dict[str, Any]] = None,
295
+ ) -> None:
265
296
  self.run_id = run_id
266
297
  self.agent_name = agent_name
267
298
  # Always populated — wrap_agent mints anon if caller didn't pass one
268
299
  # so downstream ``trodo.feedback(distinct_id=...)`` always has a target.
269
300
  self.distinct_id = distinct_id
270
301
  self.input: Optional[Union[str, Dict[str, Any]]] = None
302
+ #: The run's prompt accumulator, so set_input can register a prompt.
303
+ self._prompt_state = prompt_state
271
304
  self.output: Optional[Union[str, Dict[str, Any]]] = None
272
305
  self.metadata: Dict[str, Any] = {}
273
306
  # Manually-recorded run-level error (via set_error_summary). When set
@@ -278,7 +311,16 @@ class RunHandle:
278
311
  def set_input(self, value: Any) -> None:
279
312
  """Set the run input. Prefer a chat-message list
280
313
  ``[{"role": "user", "content": ...}, ...]`` — the backend embeds the
281
- user messages as the run's semantic input."""
314
+ user messages as the run's semantic input.
315
+
316
+ Handing this the messages from a managed prompt also records that
317
+ prompt on the run: ``run.set_input(compiled.messages)`` is a common
318
+ shape, and the run demonstrably used that prompt, so the run-level
319
+ ``trodo.prompts`` list should say so even when compile() ran outside
320
+ the run."""
321
+ ref = prompt_ref_from_carrier(value)
322
+ if ref is not None:
323
+ remember_prompt(self._prompt_state, ref)
282
324
  self.input = _prepare_value(value)
283
325
 
284
326
  def set_output(self, value: Any) -> None:
@@ -310,6 +352,10 @@ class SpanHandle:
310
352
  self.span_id = span_id
311
353
  self.name = name
312
354
  self.input: Optional[Union[str, Dict[str, Any]]] = None
355
+ #: Prompt this span used, recovered from the value passed as input.
356
+ #: Captured from the ORIGINAL object: _prepare_value JSON-serialises
357
+ #: anything that isn't a dict/list, and identity cannot survive that.
358
+ self.prompt_ref: Optional[Dict[str, Any]] = None
313
359
  self.output: Optional[Union[str, Dict[str, Any]]] = None
314
360
  self.attributes: Dict[str, Any] = {}
315
361
  self.model: Optional[str] = None
@@ -373,6 +419,7 @@ class SpanHandle:
373
419
  ``context`` for RAG docs, any order, multiple per role) — each role is
374
420
  embedded separately and powers the AI-score detectors. Anything else
375
421
  is stored as one opaque input."""
422
+ self.prompt_ref = prompt_ref_from_carrier(value) or self.prompt_ref
376
423
  self.input = _prepare_value(value)
377
424
 
378
425
  def set_output(self, value: Any) -> None:
@@ -574,7 +621,6 @@ class wrap_agent:
574
621
  self._started_iso = _now_iso()
575
622
  self._started_ms = time.time() * 1000.0
576
623
 
577
- self.handle = RunHandle(run_id, self._agent_name, self._distinct_id)
578
624
  ctx = ActiveSpanContext(
579
625
  run_id=run_id,
580
626
  span_id=root_span_id,
@@ -583,6 +629,9 @@ class wrap_agent:
583
629
  processor=self._processor,
584
630
  prompt_state=new_prompt_state(),
585
631
  )
632
+ self.handle = RunHandle(
633
+ run_id, self._agent_name, self._distinct_id, ctx.prompt_state
634
+ )
586
635
  self._ctx = ctx
587
636
  self._ctx_mgr = run_with_context(ctx)
588
637
  self._ctx_mgr.__enter__()
@@ -600,6 +649,7 @@ class wrap_agent:
600
649
  einfo = describe_error(exc_type, exc, tb)
601
650
  error_summary = einfo["error_message"]
602
651
  error_type = einfo["error_type"]
652
+ _tag_error_with_run(exc, self.handle.run_id)
603
653
  elif manual_run_error:
604
654
  status = "error"
605
655
  error_summary = self.handle.error_summary
@@ -687,7 +737,12 @@ class wrap_agent:
687
737
  otel_span.set_attribute(f"trodo.metadata.{k}", _serialize_attr(v))
688
738
 
689
739
  self._otel_span = otel_span
690
- self.handle = RunHandle(run_id, self._agent_name, self._distinct_id)
740
+ # OTLP mode builds no Trodo run context (documented limitation), so
741
+ # the handle gets a standalone accumulator — enough for set_input() to
742
+ # register a prompt even here.
743
+ self.handle = RunHandle(
744
+ run_id, self._agent_name, self._distinct_id, new_prompt_state()
745
+ )
691
746
  return self.handle
692
747
 
693
748
  def _exit_otel(self, exc_type, exc, tb) -> None:
@@ -704,6 +759,7 @@ class wrap_agent:
704
759
  for k, v in self.handle.metadata.items():
705
760
  otel_span.set_attribute(f"trodo.metadata.{k}", _serialize_attr(v))
706
761
  if exc is not None:
762
+ _tag_error_with_run(exc, self.handle.run_id if self.handle else None)
707
763
  otel_span.record_exception(exc)
708
764
  _, status_cls, status_code = get_otel_helpers()
709
765
  if status_cls is not None and status_code is not None:
@@ -748,6 +804,7 @@ class join_run:
748
804
  self._parent_span_id = parent_span_id
749
805
  self._name = name
750
806
  self._kind = kind
807
+ self._prompt_ref = prompt_ref_from_carrier(input)
751
808
  self._input = _prepare_value(input) if input is not None else None
752
809
  self._attributes = attributes
753
810
  self._ctx_mgr: Optional[run_with_context] = None
@@ -764,6 +821,8 @@ class join_run:
764
821
  self.handle = SpanHandle(self._span_id, self._name)
765
822
  if self._input is not None:
766
823
  self.handle.input = self._input
824
+ if self._prompt_ref is not None:
825
+ self.handle.prompt_ref = self._prompt_ref
767
826
  if self._attributes:
768
827
  self.handle.attributes.update(self._attributes)
769
828
 
@@ -816,7 +875,9 @@ class join_run:
816
875
  cost_details=self.handle.cost_details,
817
876
  temperature=self.handle.temperature,
818
877
  tool_name=self.handle.tool_name,
819
- attributes=merge_prompt_attrs(self._ctx, self.handle.attributes),
878
+ attributes=merge_prompt_attrs(
879
+ self._ctx, self.handle.attributes, self.handle.prompt_ref
880
+ ),
820
881
  )
821
882
  try:
822
883
  self._processor.append_spans(self._run_id, [trodo_span])
@@ -843,6 +904,7 @@ class span:
843
904
  ) -> None:
844
905
  self._name = name
845
906
  self._kind = kind
907
+ self._prompt_ref = prompt_ref_from_carrier(input)
846
908
  self._input = _prepare_value(input) if input is not None else None
847
909
  self._attributes = attributes
848
910
  self._ctx_mgr: Optional[run_with_context] = None
@@ -867,6 +929,8 @@ class span:
867
929
  self.handle = SpanHandle(self._span_id, self._name)
868
930
  if self._input is not None:
869
931
  self.handle.input = self._input
932
+ if self._prompt_ref is not None:
933
+ self.handle.prompt_ref = self._prompt_ref
870
934
  if self._attributes:
871
935
  self.handle.attributes.update(self._attributes)
872
936
  if self._active is None:
@@ -923,7 +987,9 @@ class span:
923
987
  cost_details=self.handle.cost_details,
924
988
  temperature=self.handle.temperature,
925
989
  tool_name=self.handle.tool_name,
926
- attributes=merge_prompt_attrs(self._active, self.handle.attributes),
990
+ attributes=merge_prompt_attrs(
991
+ self._active, self.handle.attributes, self.handle.prompt_ref
992
+ ),
927
993
  )
928
994
  processor: TrodoSpanProcessor = self._active.processor # type: ignore[assignment]
929
995
  processor.enqueue_span(trodo_span)
@@ -949,6 +1015,8 @@ class span:
949
1015
  self.handle = SpanHandle(self._span_id, self._name)
950
1016
  if self._input is not None:
951
1017
  self.handle.input = self._input
1018
+ if self._prompt_ref is not None:
1019
+ self.handle.prompt_ref = self._prompt_ref
952
1020
  if self._attributes:
953
1021
  self.handle.attributes.update(self._attributes)
954
1022
  return self.handle
trodo/prompts/compile.py CHANGED
@@ -8,6 +8,7 @@ or the prompt you tested is not the prompt you shipped.
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
+ import json
11
12
  from typing import Any, Dict, List, Optional
12
13
 
13
14
  from .template import render
@@ -26,6 +27,15 @@ class CompileError(Exception):
26
27
  self.details: List[str] = details or []
27
28
 
28
29
 
30
+ def _q(value: Any) -> str:
31
+ """Format an offending value the way Node's JSON.stringify does, so the two
32
+ engines produce byte-identical error text. repr() was the one divergence a
33
+ full cross-SDK parity run found."""
34
+ try:
35
+ return json.dumps(value, ensure_ascii=False)
36
+ except (TypeError, ValueError):
37
+ return repr(value)
38
+
29
39
  def _coerce(value: Any, type_: str, name: str, errors: List[str]) -> Any:
30
40
  """Coerce a caller value to the declared type.
31
41
 
@@ -36,15 +46,18 @@ def _coerce(value: Any, type_: str, name: str, errors: List[str]) -> Any:
36
46
  return value
37
47
 
38
48
  if type_ == "number":
49
+ # _q (json.dumps), not repr: the Node engine formats the offending
50
+ # value with JSON.stringify, and the two engines' error text is
51
+ # asserted byte-identical.
39
52
  if isinstance(value, bool):
40
- errors.append(f"variable '{name}': expected a number, got {value!r}")
53
+ errors.append(f"variable '{name}': expected a number, got {_q(value)}")
41
54
  return value
42
55
  if isinstance(value, (int, float)):
43
56
  return value
44
57
  try:
45
58
  return float(value) if "." in str(value) else int(value)
46
59
  except (TypeError, ValueError):
47
- errors.append(f"variable '{name}': expected a number, got {value!r}")
60
+ errors.append(f"variable '{name}': expected a number, got {_q(value)}")
48
61
  return value
49
62
 
50
63
  if type_ == "boolean":
@@ -54,7 +67,7 @@ def _coerce(value: Any, type_: str, name: str, errors: List[str]) -> Any:
54
67
  return True
55
68
  if value == "false":
56
69
  return False
57
- errors.append(f"variable '{name}': expected a boolean, got {value!r}")
70
+ errors.append(f"variable '{name}': expected a boolean, got {_q(value)}")
58
71
  return value
59
72
 
60
73
  if type_ == "string":
trodo/prompts/types.py CHANGED
@@ -89,21 +89,30 @@ class ManagedPrompt:
89
89
  values = dict(variables or {})
90
90
  values.update(kwargs)
91
91
  compiled = compile_prompt(self, values)
92
- # Prompt traceability: record the exact version compiled so any span
93
- # emitted in the surrounding wrap_agent/span scope carries it. No-op
94
- # outside a run context. Never records a fallback (no real hash).
92
+ # Prompt traceability. Never records a fallback it has no real hash,
93
+ # so a link to it would name a version that does not exist.
95
94
  if not self.is_fallback:
96
95
  try:
97
- from ..otel.prompt_trace import record_compiled_prompt
98
-
99
- record_compiled_prompt(
100
- {
101
- "name": self.name,
102
- "version_hash": self.version_hash,
103
- "content_hash": self.content_hash,
104
- "label": self.trace_label,
105
- }
96
+ from ..otel.prompt_trace import (
97
+ record_compiled_prompt,
98
+ tag_carrier_with_prompt,
106
99
  )
100
+
101
+ ref = {
102
+ "name": self.name,
103
+ "version_hash": self.version_hash,
104
+ "content_hash": self.content_hash,
105
+ "label": self.trace_label,
106
+ }
107
+ # (1) the surrounding run scope, if any — the only route that
108
+ # reaches auto-instrumented spans, whose input we never see.
109
+ record_compiled_prompt(ref)
110
+ # (2) the compiled value itself, so a span handed these messages
111
+ # as ``input`` recovers the version even when compile() ran
112
+ # outside the run. Both the wrapper and the list are tagged,
113
+ # since callers pass one or the other.
114
+ tag_carrier_with_prompt(compiled, ref)
115
+ tag_carrier_with_prompt(getattr(compiled, "messages", None), ref)
107
116
  except Exception: # noqa: BLE001
108
117
  pass # traceability must never break compile
109
118
  return compiled
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trodo-python
3
- Version: 2.20.0
3
+ Version: 2.22.0
4
4
  Summary: Trodo Analytics SDK for Python — server-side event tracking
5
5
  License: ISC
6
6
  Keywords: analytics,tracking,trodo,server-side
@@ -1,4 +1,4 @@
1
- trodo/__init__.py,sha256=ueM-l4KXHdP42DzvCRN9Zc5d4OsCxOSjiT-NIGAiLyQ,26928
1
+ trodo/__init__.py,sha256=yv0gxvU4OpZhqck-U2YY0RZE7qRWDhD4ig4zenbMPI0,26928
2
2
  trodo/client.py,sha256=9UYaHZtWPMYdlG2xWVf77PQJ9DfFgN-zobTh60i8_Cc,22453
3
3
  trodo/types.py,sha256=eySgUvCXROG2TxtxgiU0MNr5iH0DEcduK8bmYtTKG44,3138
4
4
  trodo/user_context.py,sha256=uHCI2WYoOI3cNwdIUEuZdX4VYSu14pzm348ksn6hn_c,8195
@@ -13,21 +13,21 @@ trodo/managers/dataset_manager.py,sha256=gx0S8ujG3cbw4IskBNMdceBjDbcEh_nebwKSzEv
13
13
  trodo/managers/experiment_manager.py,sha256=V-vemLtenfSI5VIcP0HPiXzEy8AyTrRrbJVCA9Z6wjo,10579
14
14
  trodo/managers/group_manager.py,sha256=ki3Se3qEoSZfREX63oeDeBmEfZF-ISHLE8azEtLg0tM,3542
15
15
  trodo/managers/people_manager.py,sha256=mMVnx40Mlifx6NGgChvohC9ViK6dQu2mkXNHbV8pK1E,2882
16
- trodo/managers/prompt_manager.py,sha256=4xj_38b2qU31aTs5fIq-wNJnPisqf1zre0u8jvjZOF4,11375
16
+ trodo/managers/prompt_manager.py,sha256=qFVHgh7PW-SlgHp62ybirdYsJBg-fcQVMTTU3jPqFvo,13435
17
17
  trodo/managers/user_manager.py,sha256=faJYX3CHrD7ulYiShV7FhThSMX9aJ3kdOgU4Qtdy5FM,2844
18
18
  trodo/otel/__init__.py,sha256=yiRFXWUU45bAM2CV37XeO7zf1hmnmjufdP4XO50yEyE,624
19
- trodo/otel/auto_instrument.py,sha256=Iae9A9lvh2PImE6gqnyEMXeRPRpjTxZcxx1zW2KDLac,21467
19
+ trodo/otel/auto_instrument.py,sha256=kcfajnmaVuX_giLYBr2P7GiWk-lzZt-iniY8rJEALYw,21819
20
20
  trodo/otel/context.py,sha256=Jd0aTc0Q-1dM5kXXinhZD8YtnpdKgvneNiODyboVGKY,1418
21
21
  trodo/otel/helpers.py,sha256=XOMWcgZHaq5SQbkFxDaXPE4CDFjn01xjJmJ1vIxvwpw,20730
22
22
  trodo/otel/processor.py,sha256=LKlXxP3BeQ7DP8SzYAgXZZOeJ6-6b7e43i19gCUC_0Y,7939
23
- trodo/otel/prompt_trace.py,sha256=BIrdLOpsR1_HoaCmWb_706GwZSs-UG3p76fjvX3CX3w,3391
23
+ trodo/otel/prompt_trace.py,sha256=X-8AIfwMmegOZl9yMifUkpYu1rV4wE6-kuYYtts0wgA,6184
24
24
  trodo/otel/register.py,sha256=bV_ePTfUvPugig2GZnylhzxi2QfoPWs96A9mGLPMrSQ,9387
25
25
  trodo/otel/transport.py,sha256=hzZz8gwSMGJ8CxdijmLn1Ljt18owr9XTWy13DLbwYbw,2441
26
- trodo/otel/wrap_agent.py,sha256=cJjrzlZNW2g6q_coLN7UR4uxn65h4u3-LNzMfE4dLVw,40999
26
+ trodo/otel/wrap_agent.py,sha256=Izty1jxF4ANVqS72L0CR9jmMuW372AApaEO_0LC5Thw,43860
27
27
  trodo/prompts/__init__.py,sha256=yunNc8WkTSfEEkRR-NZWXAt_ZB3Ee2INRh-4RE7uHj4,806
28
- trodo/prompts/compile.py,sha256=UhJhu-BtVicaauDEKpAXB17KhgVs4jWos_I60I8kZFk,6583
28
+ trodo/prompts/compile.py,sha256=fAYq55LdGIvglNVCnBF3YXyM8W9raLku1M8OAzaQExE,7135
29
29
  trodo/prompts/template.py,sha256=obQiivPCR6LEdz7q1cby7uL25tYHYui4aoEaiUNqWfs,9357
30
- trodo/prompts/types.py,sha256=A24njy6qcc8QNzWEfGu28BbBr360S7A5Pw7_ObIWNS4,4684
30
+ trodo/prompts/types.py,sha256=OYpaLi1H9pJjeq0gsJo8eb-bWoF8rw2u_7pdukudtqo,5258
31
31
  trodo/queue/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
32
32
  trodo/queue/batch_flusher.py,sha256=4Lg6T3Urwi9U0Q4FpFGPmjDYKg4ZliCTR-ND8BJvWaY,1298
33
33
  trodo/queue/event_queue.py,sha256=EVFZrhlq_kwC3jJ2GK0wMhHISf9UzLCZNDnT_aZ2I2A,872
@@ -36,7 +36,7 @@ trodo/session/server_session.py,sha256=McsudEiq33XDq3nqxgzBcUvIjQxCMscwEuAPnYXrT
36
36
  trodo/session/session_manager.py,sha256=7ht5LeeZX1HLLfPeNV_a8NkXGbHl3up0CRtCGr3EzjQ,2995
37
37
  trodo/util/__init__.py,sha256=Z9c4rPPdKg06Kk3byKheDqksSgR4WNvS475oQ5sNljc,54
38
38
  trodo/util/lru.py,sha256=QIsM7s6J_E9ZjiXatSyReZIEeGypTAcgrPWWy8YsRa4,2465
39
- trodo_python-2.20.0.dist-info/METADATA,sha256=EYdoY1cwgukIxmryCG6QadL0qiIz5J8harU3y7v6Q0Y,25308
40
- trodo_python-2.20.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
41
- trodo_python-2.20.0.dist-info/top_level.txt,sha256=VCQu1CJWFmNsqTs1YxMcw4Mq35Tc3z3uI9RwHEXAayQ,6
42
- trodo_python-2.20.0.dist-info/RECORD,,
39
+ trodo_python-2.22.0.dist-info/METADATA,sha256=RmvsK5vGdIvPBMeZqjANQygwnolV11Hb4006GzI6Xcg,25308
40
+ trodo_python-2.22.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
41
+ trodo_python-2.22.0.dist-info/top_level.txt,sha256=VCQu1CJWFmNsqTs1YxMcw4Mq35Tc3z3uI9RwHEXAayQ,6
42
+ trodo_python-2.22.0.dist-info/RECORD,,