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 +1 -1
- trodo/managers/prompt_manager.py +35 -2
- trodo/otel/auto_instrument.py +58 -53
- trodo/otel/prompt_trace.py +65 -3
- trodo/otel/wrap_agent.py +75 -7
- trodo/prompts/compile.py +16 -3
- trodo/prompts/types.py +21 -12
- {trodo_python-2.20.0.dist-info → trodo_python-2.22.0.dist-info}/METADATA +1 -1
- {trodo_python-2.20.0.dist-info → trodo_python-2.22.0.dist-info}/RECORD +11 -11
- {trodo_python-2.20.0.dist-info → trodo_python-2.22.0.dist-info}/WHEEL +0 -0
- {trodo_python-2.20.0.dist-info → trodo_python-2.22.0.dist-info}/top_level.txt +0 -0
trodo/__init__.py
CHANGED
trodo/managers/prompt_manager.py
CHANGED
|
@@ -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.
|
|
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)
|
trodo/otel/auto_instrument.py
CHANGED
|
@@ -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
|
-
|
|
420
|
-
|
|
421
|
-
AnthropicInstrumentor().instrument()
|
|
459
|
+
_instrument("opentelemetry.instrumentation.anthropic", "AnthropicInstrumentor")
|
|
422
460
|
|
|
423
461
|
def _openai() -> Any:
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
OpenAIInstrumentor().instrument()
|
|
462
|
+
_instrument("opentelemetry.instrumentation.openai", "OpenAIInstrumentor")
|
|
427
463
|
|
|
428
464
|
def _openai_v2() -> Any:
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
OpenAIInstrumentor().instrument()
|
|
465
|
+
_instrument("opentelemetry.instrumentation.openai_v2", "OpenAIInstrumentor")
|
|
432
466
|
|
|
433
467
|
def _langchain() -> Any:
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
LangChainInstrumentor().instrument()
|
|
468
|
+
_instrument("opentelemetry.instrumentation.langchain", "LangchainInstrumentor", "LangChainInstrumentor")
|
|
437
469
|
|
|
438
470
|
def _llama_index() -> Any:
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
LlamaIndexInstrumentor().instrument()
|
|
471
|
+
_instrument("opentelemetry.instrumentation.llama_index", "LlamaIndexInstrumentor")
|
|
442
472
|
|
|
443
473
|
def _google_generativeai() -> Any:
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
|
|
465
|
-
|
|
466
|
-
VertexAIInstrumentor().instrument()
|
|
483
|
+
_instrument("opentelemetry.instrumentation.vertexai", "VertexAIInstrumentor")
|
|
467
484
|
|
|
468
485
|
def _bedrock() -> Any:
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
BedrockInstrumentor().instrument()
|
|
486
|
+
_instrument("opentelemetry.instrumentation.bedrock", "BedrockInstrumentor")
|
|
472
487
|
|
|
473
488
|
def _cohere() -> Any:
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
CohereInstrumentor().instrument()
|
|
489
|
+
_instrument("opentelemetry.instrumentation.cohere", "CohereInstrumentor")
|
|
477
490
|
|
|
478
491
|
def _mistralai() -> Any:
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
MistralAiInstrumentor().instrument()
|
|
492
|
+
_instrument("opentelemetry.instrumentation.mistralai", "MistralAiInstrumentor")
|
|
482
493
|
|
|
483
494
|
def _haystack() -> Any:
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
HaystackInstrumentor().instrument()
|
|
495
|
+
_instrument("opentelemetry.instrumentation.haystack", "HaystackInstrumentor")
|
|
487
496
|
|
|
488
497
|
def _httpx() -> Any:
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
HTTPXClientInstrumentor().instrument()
|
|
498
|
+
_instrument("opentelemetry.instrumentation.httpx", "HTTPXClientInstrumentor")
|
|
492
499
|
|
|
493
500
|
def _requests() -> Any:
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
RequestsInstrumentor().instrument()
|
|
501
|
+
_instrument("opentelemetry.instrumentation.requests", "RequestsInstrumentor")
|
|
497
502
|
|
|
498
503
|
_INSTRUMENTORS = [
|
|
499
504
|
("anthropic", _anthropic),
|
trodo/otel/prompt_trace.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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__(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
93
|
-
#
|
|
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
|
|
98
|
-
|
|
99
|
-
|
|
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,4 +1,4 @@
|
|
|
1
|
-
trodo/__init__.py,sha256=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
28
|
+
trodo/prompts/compile.py,sha256=fAYq55LdGIvglNVCnBF3YXyM8W9raLku1M8OAzaQExE,7135
|
|
29
29
|
trodo/prompts/template.py,sha256=obQiivPCR6LEdz7q1cby7uL25tYHYui4aoEaiUNqWfs,9357
|
|
30
|
-
trodo/prompts/types.py,sha256=
|
|
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.
|
|
40
|
-
trodo_python-2.
|
|
41
|
-
trodo_python-2.
|
|
42
|
-
trodo_python-2.
|
|
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,,
|
|
File without changes
|
|
File without changes
|