trodo-python 2.11.0__tar.gz → 2.12.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.11.0 → trodo_python-2.12.0}/PKG-INFO +39 -1
- {trodo_python-2.11.0 → trodo_python-2.12.0}/README.md +38 -0
- {trodo_python-2.11.0 → trodo_python-2.12.0}/pyproject.toml +1 -1
- trodo_python-2.12.0/tests/test_prompts.py +325 -0
- {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo/__init__.py +96 -1
- {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo/api/http_client.py +53 -0
- {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo/client.py +9 -0
- trodo_python-2.12.0/trodo/managers/prompt_manager.py +286 -0
- trodo_python-2.12.0/trodo/prompts/__init__.py +37 -0
- trodo_python-2.12.0/trodo/prompts/compile.py +181 -0
- trodo_python-2.12.0/trodo/prompts/template.py +280 -0
- trodo_python-2.12.0/trodo/prompts/types.py +87 -0
- {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo_python.egg-info/PKG-INFO +39 -1
- {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo_python.egg-info/SOURCES.txt +6 -0
- {trodo_python-2.11.0 → trodo_python-2.12.0}/setup.cfg +0 -0
- {trodo_python-2.11.0 → trodo_python-2.12.0}/tests/test_anon_distinct_id.py +0 -0
- {trodo_python-2.11.0 → trodo_python-2.12.0}/tests/test_auto_instrument_fixes.py +0 -0
- {trodo_python-2.11.0 → trodo_python-2.12.0}/tests/test_cross_process_session.py +0 -0
- {trodo_python-2.11.0 → trodo_python-2.12.0}/tests/test_end_run.py +0 -0
- {trodo_python-2.11.0 → trodo_python-2.12.0}/tests/test_error_enrichment.py +0 -0
- {trodo_python-2.11.0 → trodo_python-2.12.0}/tests/test_llm_usage_cost.py +0 -0
- {trodo_python-2.11.0 → trodo_python-2.12.0}/tests/test_processor_methods.py +0 -0
- {trodo_python-2.11.0 → trodo_python-2.12.0}/tests/test_register_otel.py +0 -0
- {trodo_python-2.11.0 → trodo_python-2.12.0}/tests/test_start_run.py +0 -0
- {trodo_python-2.11.0 → trodo_python-2.12.0}/tests/test_wrap_agent_unchanged.py +0 -0
- {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo/api/__init__.py +0 -0
- {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo/api/async_client.py +0 -0
- {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo/api/endpoints.py +0 -0
- {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo/auto/__init__.py +0 -0
- {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo/auto/auto_event_manager.py +0 -0
- {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo/managers/__init__.py +0 -0
- {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo/managers/group_manager.py +0 -0
- {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo/managers/people_manager.py +0 -0
- {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo/otel/__init__.py +0 -0
- {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo/otel/auto_instrument.py +0 -0
- {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo/otel/context.py +0 -0
- {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo/otel/helpers.py +0 -0
- {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo/otel/processor.py +0 -0
- {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo/otel/register.py +0 -0
- {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo/otel/transport.py +0 -0
- {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo/otel/wrap_agent.py +0 -0
- {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo/queue/__init__.py +0 -0
- {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo/queue/batch_flusher.py +0 -0
- {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo/queue/event_queue.py +0 -0
- {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo/session/__init__.py +0 -0
- {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo/session/server_session.py +0 -0
- {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo/session/session_manager.py +0 -0
- {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo/types.py +0 -0
- {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo/user_context.py +0 -0
- {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo_python.egg-info/dependency_links.txt +0 -0
- {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo_python.egg-info/requires.txt +0 -0
- {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo_python.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: trodo-python
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.12.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
|
|
@@ -438,6 +438,44 @@ Runnable scenarios that double as integration tests live in
|
|
|
438
438
|
|
|
439
439
|
---
|
|
440
440
|
|
|
441
|
+
## Prompt Management (v2.9.0+)
|
|
442
|
+
|
|
443
|
+
Author and version prompts in the Trodo dashboard, then fetch them at runtime so
|
|
444
|
+
your application never hard-codes prompt text. A deploy **label** (e.g.
|
|
445
|
+
`production`) points at one version; ship a new prompt by moving the label — no
|
|
446
|
+
redeploy.
|
|
447
|
+
|
|
448
|
+
```python
|
|
449
|
+
import trodo
|
|
450
|
+
trodo.init(site_id="your-site-id")
|
|
451
|
+
|
|
452
|
+
# Latest version (default), a label, or a pinned version number:
|
|
453
|
+
prompt = trodo.get_prompt("refund-agent", label="production")
|
|
454
|
+
|
|
455
|
+
# Fill {{variables}} — unknown tokens are left intact so a missing value shows.
|
|
456
|
+
system = prompt.compile(company="Acme", customer_name="Ada")
|
|
457
|
+
|
|
458
|
+
resp = openai.chat.completions.create(
|
|
459
|
+
model=prompt.config.get("model", "gpt-4o-mini"),
|
|
460
|
+
temperature=prompt.config.get("temperature", 0.2),
|
|
461
|
+
messages=[{"role": "system", "content": system},
|
|
462
|
+
{"role": "user", "content": query}],
|
|
463
|
+
)
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
`get_prompt()` returns a `ManagedPrompt` with `name`, `version`, `labels`,
|
|
467
|
+
`template`, `config`, `variables`, and a `.compile(**vars)` method. It raises
|
|
468
|
+
`LookupError` if the prompt can't be found (you can't run without it).
|
|
469
|
+
|
|
470
|
+
```python
|
|
471
|
+
trodo.get_prompt("refund-agent") # latest version
|
|
472
|
+
trodo.get_prompt("refund-agent", version=3) # pinned version
|
|
473
|
+
trodo.list_prompts() # [PromptSummary(name=..., labels=...), ...]
|
|
474
|
+
trodo.compile_prompt(template_or_prompt, {...}) # standalone {{var}} substitution
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
---
|
|
478
|
+
|
|
441
479
|
## Agent Analytics (legacy event-based API)
|
|
442
480
|
|
|
443
481
|
The older per-event API below is still supported but superseded by
|
|
@@ -407,6 +407,44 @@ Runnable scenarios that double as integration tests live in
|
|
|
407
407
|
|
|
408
408
|
---
|
|
409
409
|
|
|
410
|
+
## Prompt Management (v2.9.0+)
|
|
411
|
+
|
|
412
|
+
Author and version prompts in the Trodo dashboard, then fetch them at runtime so
|
|
413
|
+
your application never hard-codes prompt text. A deploy **label** (e.g.
|
|
414
|
+
`production`) points at one version; ship a new prompt by moving the label — no
|
|
415
|
+
redeploy.
|
|
416
|
+
|
|
417
|
+
```python
|
|
418
|
+
import trodo
|
|
419
|
+
trodo.init(site_id="your-site-id")
|
|
420
|
+
|
|
421
|
+
# Latest version (default), a label, or a pinned version number:
|
|
422
|
+
prompt = trodo.get_prompt("refund-agent", label="production")
|
|
423
|
+
|
|
424
|
+
# Fill {{variables}} — unknown tokens are left intact so a missing value shows.
|
|
425
|
+
system = prompt.compile(company="Acme", customer_name="Ada")
|
|
426
|
+
|
|
427
|
+
resp = openai.chat.completions.create(
|
|
428
|
+
model=prompt.config.get("model", "gpt-4o-mini"),
|
|
429
|
+
temperature=prompt.config.get("temperature", 0.2),
|
|
430
|
+
messages=[{"role": "system", "content": system},
|
|
431
|
+
{"role": "user", "content": query}],
|
|
432
|
+
)
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
`get_prompt()` returns a `ManagedPrompt` with `name`, `version`, `labels`,
|
|
436
|
+
`template`, `config`, `variables`, and a `.compile(**vars)` method. It raises
|
|
437
|
+
`LookupError` if the prompt can't be found (you can't run without it).
|
|
438
|
+
|
|
439
|
+
```python
|
|
440
|
+
trodo.get_prompt("refund-agent") # latest version
|
|
441
|
+
trodo.get_prompt("refund-agent", version=3) # pinned version
|
|
442
|
+
trodo.list_prompts() # [PromptSummary(name=..., labels=...), ...]
|
|
443
|
+
trodo.compile_prompt(template_or_prompt, {...}) # standalone {{var}} substitution
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
---
|
|
447
|
+
|
|
410
448
|
## Agent Analytics (legacy event-based API)
|
|
411
449
|
|
|
412
450
|
The older per-event API below is still supported but superseded by
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""Prompt management — template engine, compile, cache, PromptManager.
|
|
2
|
+
|
|
3
|
+
Uses a fake HTTP client so no network is touched.
|
|
4
|
+
|
|
5
|
+
Mirrors ``sdks/trodo-node-sdk/tests/prompts.test.ts`` test-for-test, and loads
|
|
6
|
+
the SAME shared vector file the backend engine's tests use — that file is the
|
|
7
|
+
only thing preventing the three implementations from drifting, and a playground
|
|
8
|
+
that renders differently from the SDK is the worst bug this feature can ship.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
from trodo.managers.prompt_manager import PromptManager, _cache_key
|
|
18
|
+
from trodo.prompts.compile import CompileError, compile_prompt
|
|
19
|
+
from trodo.prompts.template import TemplateError, extract_names, render
|
|
20
|
+
|
|
21
|
+
# ════════════════════════════════════════════════════════════════════════════
|
|
22
|
+
# Shared vectors
|
|
23
|
+
# ════════════════════════════════════════════════════════════════════════════
|
|
24
|
+
|
|
25
|
+
_VECTORS_PATH = os.path.abspath(
|
|
26
|
+
os.path.join(
|
|
27
|
+
os.path.dirname(__file__),
|
|
28
|
+
"..", "..", "..", "backend", "services", "prompts", "template.vectors.json",
|
|
29
|
+
)
|
|
30
|
+
)
|
|
31
|
+
with open(_VECTORS_PATH, encoding="utf-8") as fh:
|
|
32
|
+
VECTORS = json.load(fh)
|
|
33
|
+
|
|
34
|
+
_RENDER_OK = [c for c in VECTORS["cases"] if "expect" in c]
|
|
35
|
+
_RENDER_THROWS = [c for c in VECTORS["cases"] if "throws" in c]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.mark.parametrize("case", _RENDER_OK, ids=[c["name"] for c in _RENDER_OK])
|
|
39
|
+
def test_render_vectors(case):
|
|
40
|
+
"""Must match the backend and the Node SDK byte-for-byte."""
|
|
41
|
+
strict = case.get("strict", True)
|
|
42
|
+
assert render(case["template"], case["values"], strict=strict) == case["expect"]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@pytest.mark.parametrize("case", _RENDER_THROWS, ids=[c["name"] for c in _RENDER_THROWS])
|
|
46
|
+
def test_render_vectors_raise(case):
|
|
47
|
+
strict = case.get("strict", True)
|
|
48
|
+
with pytest.raises(TemplateError) as exc:
|
|
49
|
+
render(case["template"], case["values"], strict=strict)
|
|
50
|
+
assert case["throws"] in str(exc.value)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@pytest.mark.parametrize(
|
|
54
|
+
"case",
|
|
55
|
+
VECTORS["extractNames"],
|
|
56
|
+
ids=[repr(c["template"]) for c in VECTORS["extractNames"]],
|
|
57
|
+
)
|
|
58
|
+
def test_extract_names_vectors(case):
|
|
59
|
+
assert extract_names(case["template"]) == case["expect"]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_no_inherited_attribute_resolution():
|
|
63
|
+
"""`{{items}}` must not resolve to dict.items.
|
|
64
|
+
|
|
65
|
+
A Python-specific hazard the JS ports don't have — guarded by using `in` on
|
|
66
|
+
dicts rather than getattr.
|
|
67
|
+
"""
|
|
68
|
+
with pytest.raises(TemplateError):
|
|
69
|
+
render("{{items}}", {})
|
|
70
|
+
with pytest.raises(TemplateError):
|
|
71
|
+
render("{{keys}}", {})
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ════════════════════════════════════════════════════════════════════════════
|
|
75
|
+
# compile
|
|
76
|
+
# ════════════════════════════════════════════════════════════════════════════
|
|
77
|
+
|
|
78
|
+
PROMPT = {
|
|
79
|
+
"name": "greeter",
|
|
80
|
+
"messages": [
|
|
81
|
+
{"role": "system", "content": [{"type": "text", "text": "You are {{persona}}."}]},
|
|
82
|
+
{"role": "user", "content": [{"type": "text", "text": "Hello {{who}}"}]},
|
|
83
|
+
],
|
|
84
|
+
"model_config": {"provider": "openai", "model": "gpt-4o", "temperature": 0.3},
|
|
85
|
+
"tools": [],
|
|
86
|
+
"response_format": None,
|
|
87
|
+
"variables": [
|
|
88
|
+
{"name": "persona", "type": "string", "default": "a greeter"},
|
|
89
|
+
{"name": "who", "type": "string", "required": True},
|
|
90
|
+
],
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_compile_returns_structured_messages_not_a_string():
|
|
95
|
+
out = compile_prompt(PROMPT, {"who": "world"})
|
|
96
|
+
assert not isinstance(out, str)
|
|
97
|
+
assert len(out.messages) == 2
|
|
98
|
+
assert out.messages[0]["content"][0] == {"type": "text", "text": "You are a greeter."}
|
|
99
|
+
assert out.messages[1]["content"][0] == {"type": "text", "text": "Hello world"}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_compile_carries_model_config_and_tools():
|
|
103
|
+
out = compile_prompt(PROMPT, {"who": "world"})
|
|
104
|
+
assert out.model == {"provider": "openai", "model": "gpt-4o", "temperature": 0.3}
|
|
105
|
+
assert out.tools == []
|
|
106
|
+
assert out.response_format is None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_compile_renders_missing_variable_empty():
|
|
110
|
+
# `who` has no value and no default -> empty, not a raise. Nothing is required.
|
|
111
|
+
out = compile_prompt(PROMPT, {})
|
|
112
|
+
assert out.messages[1]["content"][0] == {"type": "text", "text": "Hello "}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_compile_rejects_undeclared_variable():
|
|
116
|
+
# Passing a variable the prompt doesn't declare is the one variable mistake
|
|
117
|
+
# compile still catches (it's almost always a typo/rename).
|
|
118
|
+
with pytest.raises(CompileError, match="'extra' is not declared"):
|
|
119
|
+
compile_prompt(PROMPT, {"who": "x", "extra": "y"})
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_compile_applies_defaults():
|
|
123
|
+
out = compile_prompt(PROMPT, {"who": "w"})
|
|
124
|
+
assert out.messages[0]["content"][0]["text"] == "You are a greeter."
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_compile_treats_values_as_inert_data():
|
|
128
|
+
out = compile_prompt(PROMPT, {"persona": "{{who}}", "who": "SECRET"})
|
|
129
|
+
assert out.messages[0]["content"][0]["text"] == "You are {{who}}."
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_compile_coerces_declared_types():
|
|
133
|
+
p = {
|
|
134
|
+
"messages": [{"role": "user", "content": [{"type": "text", "text": "n={{n}}"}]}],
|
|
135
|
+
"model_config": {},
|
|
136
|
+
"tools": [],
|
|
137
|
+
"response_format": None,
|
|
138
|
+
"variables": [{"name": "n", "type": "number"}],
|
|
139
|
+
}
|
|
140
|
+
assert compile_prompt(p, {"n": "5"}).messages[0]["content"][0]["text"] == "n=5"
|
|
141
|
+
with pytest.raises(CompileError, match="expected a number"):
|
|
142
|
+
compile_prompt(p, {"n": "abc"})
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_compile_expands_placeholders_and_preserves_injected_messages():
|
|
146
|
+
p = {
|
|
147
|
+
"messages": [
|
|
148
|
+
{"role": "system", "content": [{"type": "text", "text": "sys"}]},
|
|
149
|
+
{"type": "placeholder", "name": "hist"},
|
|
150
|
+
],
|
|
151
|
+
"model_config": {},
|
|
152
|
+
"tools": [],
|
|
153
|
+
"response_format": None,
|
|
154
|
+
"variables": [{"name": "hist", "type": "messages"}],
|
|
155
|
+
}
|
|
156
|
+
injected = {
|
|
157
|
+
"role": "assistant",
|
|
158
|
+
"content": [{"type": "tool_use", "id": "t1", "name": "search", "input": {"q": "x"}}],
|
|
159
|
+
}
|
|
160
|
+
out = compile_prompt(p, {"hist": [injected]})
|
|
161
|
+
assert len(out.messages) == 2
|
|
162
|
+
assert out.messages[1] == injected # tool_use survives injection
|
|
163
|
+
|
|
164
|
+
assert len(compile_prompt(p, {"hist": []}).messages) == 1
|
|
165
|
+
|
|
166
|
+
# Refuse rather than coerce to a sentinel role that would reach the provider.
|
|
167
|
+
with pytest.raises(CompileError, match="expected an array of messages"):
|
|
168
|
+
compile_prompt(p, {"hist": "oops"})
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ════════════════════════════════════════════════════════════════════════════
|
|
172
|
+
# PromptManager
|
|
173
|
+
# ════════════════════════════════════════════════════════════════════════════
|
|
174
|
+
|
|
175
|
+
WIRE = dict(
|
|
176
|
+
PROMPT,
|
|
177
|
+
version=2,
|
|
178
|
+
labels=["production"],
|
|
179
|
+
tags=["support"],
|
|
180
|
+
description="says hi",
|
|
181
|
+
updated_at="2026-06-29T00:00:00Z",
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class FakeHttp:
|
|
186
|
+
"""Records the last call so we can assert on it; mirrors the Node fakeApi."""
|
|
187
|
+
|
|
188
|
+
def __init__(self, prompt=None, prompts=None, fail=False):
|
|
189
|
+
self._prompt = prompt
|
|
190
|
+
self._prompts = prompts
|
|
191
|
+
self.fail = fail
|
|
192
|
+
self.calls = 0
|
|
193
|
+
self.last = None
|
|
194
|
+
|
|
195
|
+
def get_prompt(self, name, label=None, version=None):
|
|
196
|
+
self.calls += 1
|
|
197
|
+
self.last = {"name": name, "label": label, "version": version}
|
|
198
|
+
if self.fail:
|
|
199
|
+
raise RuntimeError("API down")
|
|
200
|
+
return {"prompt": self._prompt}
|
|
201
|
+
|
|
202
|
+
def list_prompts(self):
|
|
203
|
+
return {"prompts": self._prompts or []}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def test_get_maps_wire_shape_and_binds_compile():
|
|
207
|
+
mgr = PromptManager(FakeHttp(prompt=WIRE))
|
|
208
|
+
p = mgr.get("greeter", cache_ttl_seconds=0)
|
|
209
|
+
|
|
210
|
+
assert p.name == "greeter"
|
|
211
|
+
assert p.version == 2
|
|
212
|
+
assert p.labels == ["production"]
|
|
213
|
+
assert p.tags == ["support"]
|
|
214
|
+
assert p.model_config["model"] == "gpt-4o"
|
|
215
|
+
assert p.is_fallback is False
|
|
216
|
+
assert p.compile(who="world").messages[1]["content"][0]["text"] == "Hello world"
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def test_get_passes_selector_through():
|
|
220
|
+
http = FakeHttp(prompt=WIRE)
|
|
221
|
+
PromptManager(http).get("greeter", label="staging", cache_ttl_seconds=0)
|
|
222
|
+
assert http.last == {"name": "greeter", "label": "staging", "version": None}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def test_get_requires_a_name():
|
|
226
|
+
with pytest.raises(ValueError, match="requires a name"):
|
|
227
|
+
PromptManager(FakeHttp()).get("")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def test_get_refuses_version_and_label_together():
|
|
231
|
+
with pytest.raises(ValueError, match="not both"):
|
|
232
|
+
PromptManager(FakeHttp()).get("greeter", label="production", version=1)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def test_get_raises_when_prompt_missing():
|
|
236
|
+
class Missing:
|
|
237
|
+
def get_prompt(self, name, label=None, version=None):
|
|
238
|
+
return {"__error": True, "status": 404, "error": "prompt_not_found"}
|
|
239
|
+
|
|
240
|
+
with pytest.raises(LookupError, match="404"):
|
|
241
|
+
PromptManager(Missing()).get("nope", cache_ttl_seconds=0, max_retries=0)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def test_cache_serves_fresh_without_refetching():
|
|
245
|
+
http = FakeHttp(prompt=WIRE)
|
|
246
|
+
mgr = PromptManager(http)
|
|
247
|
+
mgr.get("greeter")
|
|
248
|
+
mgr.get("greeter")
|
|
249
|
+
assert http.calls == 1
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def test_cache_ttl_zero_bypasses_cache():
|
|
253
|
+
http = FakeHttp(prompt=WIRE)
|
|
254
|
+
mgr = PromptManager(http)
|
|
255
|
+
mgr.get("greeter", cache_ttl_seconds=0)
|
|
256
|
+
mgr.get("greeter", cache_ttl_seconds=0)
|
|
257
|
+
assert http.calls == 2
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def test_stale_cache_survives_an_outage():
|
|
261
|
+
http = FakeHttp(prompt=WIRE)
|
|
262
|
+
mgr = PromptManager(http)
|
|
263
|
+
assert mgr.get("greeter", cache_ttl_seconds=0.01).version == 2
|
|
264
|
+
|
|
265
|
+
time.sleep(0.05)
|
|
266
|
+
http.fail = True
|
|
267
|
+
# Stale-while-revalidate: the caller gets the cached prompt, not an error.
|
|
268
|
+
assert mgr.get("greeter", cache_ttl_seconds=0.01).version == 2
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def test_fallback_used_when_api_down_and_nothing_cached():
|
|
272
|
+
mgr = PromptManager(FakeHttp(fail=True))
|
|
273
|
+
p = mgr.get(
|
|
274
|
+
"greeter",
|
|
275
|
+
cache_ttl_seconds=0,
|
|
276
|
+
max_retries=0,
|
|
277
|
+
fallback={
|
|
278
|
+
"messages": [{"role": "user", "content": [{"type": "text", "text": "Hi {{who}}"}]}],
|
|
279
|
+
"variables": [{"name": "who", "type": "string", "required": True}],
|
|
280
|
+
},
|
|
281
|
+
)
|
|
282
|
+
# The point of the ladder: a cold-start outage degrades the app rather than
|
|
283
|
+
# taking it down.
|
|
284
|
+
assert p.is_fallback is True
|
|
285
|
+
assert p.compile(who="world").messages[0]["content"][0]["text"] == "Hi world"
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def test_raises_when_api_down_with_no_cache_and_no_fallback():
|
|
289
|
+
mgr = PromptManager(FakeHttp(fail=True))
|
|
290
|
+
with pytest.raises(RuntimeError, match="API down"):
|
|
291
|
+
mgr.get("greeter", cache_ttl_seconds=0, max_retries=0)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def test_cache_key_matches_the_node_sdk():
|
|
295
|
+
assert _cache_key("p", None, None) == "p::label:production"
|
|
296
|
+
assert _cache_key("p", None, "staging") == "p::label:staging"
|
|
297
|
+
assert _cache_key("p", 3, None) == "p::version:3"
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def test_list_maps_summaries():
|
|
301
|
+
mgr = PromptManager(
|
|
302
|
+
FakeHttp(
|
|
303
|
+
prompts=[
|
|
304
|
+
{
|
|
305
|
+
"name": "a",
|
|
306
|
+
"version": 1,
|
|
307
|
+
"labels": ["production"],
|
|
308
|
+
"updated_at": "2026-06-29T00:00:00Z",
|
|
309
|
+
},
|
|
310
|
+
]
|
|
311
|
+
)
|
|
312
|
+
)
|
|
313
|
+
out = mgr.list()
|
|
314
|
+
assert len(out) == 1
|
|
315
|
+
assert out[0].name == "a"
|
|
316
|
+
assert out[0].labels == ["production"]
|
|
317
|
+
assert out[0].updated_at == "2026-06-29T00:00:00Z"
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def test_list_returns_empty_on_error():
|
|
321
|
+
class Err:
|
|
322
|
+
def list_prompts(self):
|
|
323
|
+
return {"__error": True}
|
|
324
|
+
|
|
325
|
+
assert PromptManager(Err()).list() == []
|
|
@@ -41,13 +41,21 @@ 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.
|
|
44
|
+
__version__ = "2.12.0"
|
|
45
45
|
|
|
46
46
|
from typing import Any, Callable, Dict, List, Optional, Union
|
|
47
47
|
|
|
48
48
|
from .client import TrodoClient
|
|
49
49
|
from .user_context import UserContext
|
|
50
50
|
from .managers.group_manager import GroupProfile
|
|
51
|
+
from .managers.prompt_manager import ManagedPrompt, PromptSummary
|
|
52
|
+
from .prompts import (
|
|
53
|
+
CompiledPrompt,
|
|
54
|
+
CompileError,
|
|
55
|
+
PromptVariable,
|
|
56
|
+
TemplateError,
|
|
57
|
+
render as _render_template,
|
|
58
|
+
)
|
|
51
59
|
from .otel.wrap_agent import (
|
|
52
60
|
wrap_agent as _wrap_agent_ctx,
|
|
53
61
|
span as _span_ctx,
|
|
@@ -100,6 +108,17 @@ __all__ = [
|
|
|
100
108
|
"propagation_headers",
|
|
101
109
|
"current_run_id",
|
|
102
110
|
"current_span_id",
|
|
111
|
+
# Prompt management
|
|
112
|
+
"get_prompt",
|
|
113
|
+
"list_prompts",
|
|
114
|
+
"compile_prompt",
|
|
115
|
+
"render_template",
|
|
116
|
+
"CompiledPrompt",
|
|
117
|
+
"PromptVariable",
|
|
118
|
+
"CompileError",
|
|
119
|
+
"TemplateError",
|
|
120
|
+
"ManagedPrompt",
|
|
121
|
+
"PromptSummary",
|
|
103
122
|
]
|
|
104
123
|
|
|
105
124
|
# ============================================================================
|
|
@@ -249,6 +268,82 @@ def reset(distinct_id: str) -> ResetResult:
|
|
|
249
268
|
return _get_client().reset(distinct_id)
|
|
250
269
|
|
|
251
270
|
|
|
271
|
+
# ----------------------------------------------------------------------------
|
|
272
|
+
# Prompt management
|
|
273
|
+
# ----------------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
def get_prompt(
|
|
276
|
+
name: str,
|
|
277
|
+
label: Optional[str] = None,
|
|
278
|
+
version: Optional[int] = None,
|
|
279
|
+
cache_ttl_seconds: Optional[float] = None,
|
|
280
|
+
fallback: Optional[Dict[str, Any]] = None,
|
|
281
|
+
max_retries: int = 2,
|
|
282
|
+
) -> ManagedPrompt:
|
|
283
|
+
"""Fetch a managed prompt by name.
|
|
284
|
+
|
|
285
|
+
Follows the ``production`` label by default; pass ``label`` for a different
|
|
286
|
+
one or ``version`` to pin exactly.
|
|
287
|
+
|
|
288
|
+
Cached for 60s with stale-while-revalidate, so a Trodo outage degrades
|
|
289
|
+
instead of taking your app down. Pass ``fallback`` to cover cold start::
|
|
290
|
+
|
|
291
|
+
p = trodo.get_prompt("support-triage", fallback={
|
|
292
|
+
"messages": [{"role": "user", "content": [{"type": "text", "text": "Help with {{q}}"}]}],
|
|
293
|
+
"variables": [{"name": "q", "type": "string", "required": True}],
|
|
294
|
+
})
|
|
295
|
+
compiled = p.compile(q="where is my order")
|
|
296
|
+
# compiled.messages / compiled.model / compiled.tools
|
|
297
|
+
|
|
298
|
+
:raises LookupError: if the prompt cannot be fetched and nothing is cached
|
|
299
|
+
and no ``fallback`` was given
|
|
300
|
+
"""
|
|
301
|
+
return _get_client().prompts.get(
|
|
302
|
+
name,
|
|
303
|
+
label=label,
|
|
304
|
+
version=version,
|
|
305
|
+
cache_ttl_seconds=cache_ttl_seconds,
|
|
306
|
+
fallback=fallback,
|
|
307
|
+
max_retries=max_retries,
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def list_prompts() -> List[PromptSummary]:
|
|
312
|
+
"""List prompts available to this site's team (names + labels, no body)."""
|
|
313
|
+
return _get_client().prompts.list()
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def compile_prompt(
|
|
317
|
+
prompt: ManagedPrompt,
|
|
318
|
+
variables: Optional[Dict[str, Any]] = None,
|
|
319
|
+
**kwargs: Any,
|
|
320
|
+
) -> CompiledPrompt:
|
|
321
|
+
"""Compile a managed prompt with variable values.
|
|
322
|
+
|
|
323
|
+
Returns ``CompiledPrompt(messages, model, tools, response_format)`` — hand it
|
|
324
|
+
straight to your provider client.
|
|
325
|
+
"""
|
|
326
|
+
merged = dict(variables or {})
|
|
327
|
+
merged.update(kwargs)
|
|
328
|
+
return prompt.compile(merged)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def render_template(
|
|
332
|
+
template: str,
|
|
333
|
+
variables: Optional[Dict[str, Any]] = None,
|
|
334
|
+
strict: bool = True,
|
|
335
|
+
**kwargs: Any,
|
|
336
|
+
) -> str:
|
|
337
|
+
"""Render a template string with ``{{variables}}`` (no client required).
|
|
338
|
+
|
|
339
|
+
Strict by default: an unknown variable raises rather than silently leaving a
|
|
340
|
+
literal ``{{typo}}`` in the text you send to a model.
|
|
341
|
+
"""
|
|
342
|
+
merged = dict(variables or {})
|
|
343
|
+
merged.update(kwargs)
|
|
344
|
+
return _render_template(template, merged, strict=strict)
|
|
345
|
+
|
|
346
|
+
|
|
252
347
|
def enable_auto_events() -> None:
|
|
253
348
|
_get_client().enable_auto_events()
|
|
254
349
|
|
|
@@ -80,6 +80,59 @@ class HttpClient:
|
|
|
80
80
|
self.on_error(exc)
|
|
81
81
|
return {}
|
|
82
82
|
|
|
83
|
+
def _get(
|
|
84
|
+
self, path: str, params: Optional[Dict[str, Any]] = None, attempt: int = 0
|
|
85
|
+
) -> ApiResult:
|
|
86
|
+
"""GET helper for read endpoints (prompt management). Surfaces a non-2xx
|
|
87
|
+
as {"__error": True, "status": <code>, ...} so callers can tell
|
|
88
|
+
"not found" apart from a successful empty body."""
|
|
89
|
+
url = f"{self.api_base}{path}"
|
|
90
|
+
self._log(f"GET {url}")
|
|
91
|
+
try:
|
|
92
|
+
resp = self._session.get(url, params=params or {}, timeout=self.timeout)
|
|
93
|
+
if resp.status_code >= 500 and attempt < self.retries:
|
|
94
|
+
delay = 2 ** attempt
|
|
95
|
+
self._log(f"Retry {attempt + 1} after {delay}s (status {resp.status_code})")
|
|
96
|
+
time.sleep(delay)
|
|
97
|
+
return self._get(path, params, attempt + 1)
|
|
98
|
+
try:
|
|
99
|
+
data = resp.json()
|
|
100
|
+
except Exception:
|
|
101
|
+
data = {}
|
|
102
|
+
if resp.status_code >= 400:
|
|
103
|
+
base = {"__error": True, "status": resp.status_code}
|
|
104
|
+
if isinstance(data, dict):
|
|
105
|
+
base.update(data)
|
|
106
|
+
return base
|
|
107
|
+
return data
|
|
108
|
+
except Exception as exc:
|
|
109
|
+
if attempt < self.retries:
|
|
110
|
+
delay = 2 ** attempt
|
|
111
|
+
self._log(f"Retry {attempt + 1} after {delay}s (network error)")
|
|
112
|
+
time.sleep(delay)
|
|
113
|
+
return self._get(path, params, attempt + 1)
|
|
114
|
+
self._log(f"Error: {exc}")
|
|
115
|
+
if self.on_error:
|
|
116
|
+
self.on_error(exc)
|
|
117
|
+
return {"__error": True}
|
|
118
|
+
|
|
119
|
+
def get_prompt(
|
|
120
|
+
self,
|
|
121
|
+
name: str,
|
|
122
|
+
label: Optional[str] = None,
|
|
123
|
+
version: Optional[Any] = None,
|
|
124
|
+
) -> ApiResult:
|
|
125
|
+
from urllib.parse import quote
|
|
126
|
+
params: Dict[str, Any] = {}
|
|
127
|
+
if label:
|
|
128
|
+
params["label"] = label
|
|
129
|
+
if version is not None and version != "":
|
|
130
|
+
params["version"] = version
|
|
131
|
+
return self._get(f"/api/sdk/prompts/{quote(str(name), safe='')}", params)
|
|
132
|
+
|
|
133
|
+
def list_prompts(self) -> ApiResult:
|
|
134
|
+
return self._get("/api/sdk/prompts")
|
|
135
|
+
|
|
83
136
|
def post_track(self, session_data: Dict[str, Any]) -> ApiResult:
|
|
84
137
|
return self._request("/api/sdk/track", {"sessionData": session_data})
|
|
85
138
|
|
|
@@ -76,6 +76,7 @@ class TrodoClient:
|
|
|
76
76
|
)
|
|
77
77
|
|
|
78
78
|
self._session_manager = SessionManager()
|
|
79
|
+
self._prompts = None # lazily-built PromptManager
|
|
79
80
|
|
|
80
81
|
if batch_enabled:
|
|
81
82
|
self._event_queue: Optional[EventQueue] = EventQueue(batch_size)
|
|
@@ -116,6 +117,14 @@ class TrodoClient:
|
|
|
116
117
|
the public surface; subject to change between minors."""
|
|
117
118
|
return self._span_processor
|
|
118
119
|
|
|
120
|
+
@property
|
|
121
|
+
def prompts(self):
|
|
122
|
+
"""Read-only access to the team's managed prompt registry."""
|
|
123
|
+
if self._prompts is None:
|
|
124
|
+
from .managers.prompt_manager import PromptManager
|
|
125
|
+
self._prompts = PromptManager(self._http)
|
|
126
|
+
return self._prompts
|
|
127
|
+
|
|
119
128
|
# --------------------------------------------------------------------------
|
|
120
129
|
# Primary pattern: for_user()
|
|
121
130
|
# --------------------------------------------------------------------------
|