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.
Files changed (52) hide show
  1. {trodo_python-2.11.0 → trodo_python-2.12.0}/PKG-INFO +39 -1
  2. {trodo_python-2.11.0 → trodo_python-2.12.0}/README.md +38 -0
  3. {trodo_python-2.11.0 → trodo_python-2.12.0}/pyproject.toml +1 -1
  4. trodo_python-2.12.0/tests/test_prompts.py +325 -0
  5. {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo/__init__.py +96 -1
  6. {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo/api/http_client.py +53 -0
  7. {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo/client.py +9 -0
  8. trodo_python-2.12.0/trodo/managers/prompt_manager.py +286 -0
  9. trodo_python-2.12.0/trodo/prompts/__init__.py +37 -0
  10. trodo_python-2.12.0/trodo/prompts/compile.py +181 -0
  11. trodo_python-2.12.0/trodo/prompts/template.py +280 -0
  12. trodo_python-2.12.0/trodo/prompts/types.py +87 -0
  13. {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo_python.egg-info/PKG-INFO +39 -1
  14. {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo_python.egg-info/SOURCES.txt +6 -0
  15. {trodo_python-2.11.0 → trodo_python-2.12.0}/setup.cfg +0 -0
  16. {trodo_python-2.11.0 → trodo_python-2.12.0}/tests/test_anon_distinct_id.py +0 -0
  17. {trodo_python-2.11.0 → trodo_python-2.12.0}/tests/test_auto_instrument_fixes.py +0 -0
  18. {trodo_python-2.11.0 → trodo_python-2.12.0}/tests/test_cross_process_session.py +0 -0
  19. {trodo_python-2.11.0 → trodo_python-2.12.0}/tests/test_end_run.py +0 -0
  20. {trodo_python-2.11.0 → trodo_python-2.12.0}/tests/test_error_enrichment.py +0 -0
  21. {trodo_python-2.11.0 → trodo_python-2.12.0}/tests/test_llm_usage_cost.py +0 -0
  22. {trodo_python-2.11.0 → trodo_python-2.12.0}/tests/test_processor_methods.py +0 -0
  23. {trodo_python-2.11.0 → trodo_python-2.12.0}/tests/test_register_otel.py +0 -0
  24. {trodo_python-2.11.0 → trodo_python-2.12.0}/tests/test_start_run.py +0 -0
  25. {trodo_python-2.11.0 → trodo_python-2.12.0}/tests/test_wrap_agent_unchanged.py +0 -0
  26. {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo/api/__init__.py +0 -0
  27. {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo/api/async_client.py +0 -0
  28. {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo/api/endpoints.py +0 -0
  29. {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo/auto/__init__.py +0 -0
  30. {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo/auto/auto_event_manager.py +0 -0
  31. {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo/managers/__init__.py +0 -0
  32. {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo/managers/group_manager.py +0 -0
  33. {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo/managers/people_manager.py +0 -0
  34. {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo/otel/__init__.py +0 -0
  35. {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo/otel/auto_instrument.py +0 -0
  36. {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo/otel/context.py +0 -0
  37. {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo/otel/helpers.py +0 -0
  38. {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo/otel/processor.py +0 -0
  39. {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo/otel/register.py +0 -0
  40. {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo/otel/transport.py +0 -0
  41. {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo/otel/wrap_agent.py +0 -0
  42. {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo/queue/__init__.py +0 -0
  43. {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo/queue/batch_flusher.py +0 -0
  44. {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo/queue/event_queue.py +0 -0
  45. {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo/session/__init__.py +0 -0
  46. {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo/session/server_session.py +0 -0
  47. {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo/session/session_manager.py +0 -0
  48. {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo/types.py +0 -0
  49. {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo/user_context.py +0 -0
  50. {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo_python.egg-info/dependency_links.txt +0 -0
  51. {trodo_python-2.11.0 → trodo_python-2.12.0}/trodo_python.egg-info/requires.txt +0 -0
  52. {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.11.0
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "trodo-python"
7
- version = "2.11.0"
7
+ version = "2.12.0"
8
8
  description = "Trodo Analytics SDK for Python — server-side event tracking"
9
9
  readme = "README.md"
10
10
  license = { text = "ISC" }
@@ -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.11.0"
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
  # --------------------------------------------------------------------------