trodo-python 2.10.11__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 (53) hide show
  1. {trodo_python-2.10.11 → trodo_python-2.12.0}/PKG-INFO +65 -2
  2. {trodo_python-2.10.11 → trodo_python-2.12.0}/README.md +64 -1
  3. {trodo_python-2.10.11 → 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.10.11 → trodo_python-2.12.0}/tests/test_wrap_agent_unchanged.py +22 -0
  6. {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo/__init__.py +98 -2
  7. {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo/api/http_client.py +53 -0
  8. {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo/client.py +9 -0
  9. trodo_python-2.12.0/trodo/managers/prompt_manager.py +286 -0
  10. {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo/otel/helpers.py +13 -1
  11. {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo/otel/wrap_agent.py +16 -5
  12. trodo_python-2.12.0/trodo/prompts/__init__.py +37 -0
  13. trodo_python-2.12.0/trodo/prompts/compile.py +181 -0
  14. trodo_python-2.12.0/trodo/prompts/template.py +280 -0
  15. trodo_python-2.12.0/trodo/prompts/types.py +87 -0
  16. {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo_python.egg-info/PKG-INFO +65 -2
  17. {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo_python.egg-info/SOURCES.txt +6 -1
  18. trodo_python-2.10.11/tests/test_anon_distinct_id 2.py +0 -100
  19. {trodo_python-2.10.11 → trodo_python-2.12.0}/setup.cfg +0 -0
  20. {trodo_python-2.10.11 → trodo_python-2.12.0}/tests/test_anon_distinct_id.py +0 -0
  21. {trodo_python-2.10.11 → trodo_python-2.12.0}/tests/test_auto_instrument_fixes.py +0 -0
  22. {trodo_python-2.10.11 → trodo_python-2.12.0}/tests/test_cross_process_session.py +0 -0
  23. {trodo_python-2.10.11 → trodo_python-2.12.0}/tests/test_end_run.py +0 -0
  24. {trodo_python-2.10.11 → trodo_python-2.12.0}/tests/test_error_enrichment.py +0 -0
  25. {trodo_python-2.10.11 → trodo_python-2.12.0}/tests/test_llm_usage_cost.py +0 -0
  26. {trodo_python-2.10.11 → trodo_python-2.12.0}/tests/test_processor_methods.py +0 -0
  27. {trodo_python-2.10.11 → trodo_python-2.12.0}/tests/test_register_otel.py +0 -0
  28. {trodo_python-2.10.11 → trodo_python-2.12.0}/tests/test_start_run.py +0 -0
  29. {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo/api/__init__.py +0 -0
  30. {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo/api/async_client.py +0 -0
  31. {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo/api/endpoints.py +0 -0
  32. {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo/auto/__init__.py +0 -0
  33. {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo/auto/auto_event_manager.py +0 -0
  34. {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo/managers/__init__.py +0 -0
  35. {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo/managers/group_manager.py +0 -0
  36. {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo/managers/people_manager.py +0 -0
  37. {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo/otel/__init__.py +0 -0
  38. {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo/otel/auto_instrument.py +0 -0
  39. {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo/otel/context.py +0 -0
  40. {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo/otel/processor.py +0 -0
  41. {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo/otel/register.py +0 -0
  42. {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo/otel/transport.py +0 -0
  43. {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo/queue/__init__.py +0 -0
  44. {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo/queue/batch_flusher.py +0 -0
  45. {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo/queue/event_queue.py +0 -0
  46. {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo/session/__init__.py +0 -0
  47. {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo/session/server_session.py +0 -0
  48. {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo/session/session_manager.py +0 -0
  49. {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo/types.py +0 -0
  50. {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo/user_context.py +0 -0
  51. {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo_python.egg-info/dependency_links.txt +0 -0
  52. {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo_python.egg-info/requires.txt +0 -0
  53. {trodo_python-2.10.11 → 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.10.11
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
@@ -250,6 +250,30 @@ search = trodo.retrieval('vector_search', vector_search)
250
250
  docs = search(query)
251
251
  ```
252
252
 
253
+ ### LLM span input: the chat-message list
254
+
255
+ For LLM spans, set the input to the **same messages you send to the model** —
256
+ a chat-message list, not one blob:
257
+
258
+ ```python
259
+ span.set_input([
260
+ {"role": "system", "content": system_prompt}, # rules & role
261
+ {"role": "context", "content": retrieved_docs}, # RAG docs (Trodo extension)
262
+ {"role": "user", "content": "Where is my order?"},
263
+ {"role": "assistant", "content": None, "tool_calls": [...]},
264
+ {"role": "tool", "content": '{"status": "shipped"}', "tool_call_id": "c1"},
265
+ {"role": "user", "content": "When will it arrive?"}, # multiple turns are fine
266
+ ])
267
+ ```
268
+
269
+ Roles: the standard `system` / `user` / `assistant` / `tool` plus `context` —
270
+ a Trodo extension for RAG / retrieved documents. Any order, any number per
271
+ role; aliases `developer` / `model` / `function` normalise automatically.
272
+ Trodo embeds the input as a whole **and each role separately**, which powers
273
+ the AI-score detectors (system → rule adherence; user → trajectory/echo;
274
+ context else tool+assistant → grounding, contradiction, factual retention).
275
+ A plain string still works and is embedded as one vector.
276
+
253
277
  ### Raw-HTTP escape hatches
254
278
 
255
279
  If your LLM client isn't OTel-instrumented and you can't wrap it as a
@@ -261,7 +285,8 @@ trodo.track_llm_call(
261
285
  model='gemini-2.5-flash', provider='google',
262
286
  input_tokens=resp['usageMetadata']['promptTokenCount'],
263
287
  output_tokens=resp['usageMetadata']['candidatesTokenCount'],
264
- prompt=body, completion=resp,
288
+ prompt=body['messages'], # the chat-message list sent to the model
289
+ completion=resp,
265
290
  )
266
291
  ```
267
292
 
@@ -413,6 +438,44 @@ Runnable scenarios that double as integration tests live in
413
438
 
414
439
  ---
415
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
+
416
479
  ## Agent Analytics (legacy event-based API)
417
480
 
418
481
  The older per-event API below is still supported but superseded by
@@ -219,6 +219,30 @@ search = trodo.retrieval('vector_search', vector_search)
219
219
  docs = search(query)
220
220
  ```
221
221
 
222
+ ### LLM span input: the chat-message list
223
+
224
+ For LLM spans, set the input to the **same messages you send to the model** —
225
+ a chat-message list, not one blob:
226
+
227
+ ```python
228
+ span.set_input([
229
+ {"role": "system", "content": system_prompt}, # rules & role
230
+ {"role": "context", "content": retrieved_docs}, # RAG docs (Trodo extension)
231
+ {"role": "user", "content": "Where is my order?"},
232
+ {"role": "assistant", "content": None, "tool_calls": [...]},
233
+ {"role": "tool", "content": '{"status": "shipped"}', "tool_call_id": "c1"},
234
+ {"role": "user", "content": "When will it arrive?"}, # multiple turns are fine
235
+ ])
236
+ ```
237
+
238
+ Roles: the standard `system` / `user` / `assistant` / `tool` plus `context` —
239
+ a Trodo extension for RAG / retrieved documents. Any order, any number per
240
+ role; aliases `developer` / `model` / `function` normalise automatically.
241
+ Trodo embeds the input as a whole **and each role separately**, which powers
242
+ the AI-score detectors (system → rule adherence; user → trajectory/echo;
243
+ context else tool+assistant → grounding, contradiction, factual retention).
244
+ A plain string still works and is embedded as one vector.
245
+
222
246
  ### Raw-HTTP escape hatches
223
247
 
224
248
  If your LLM client isn't OTel-instrumented and you can't wrap it as a
@@ -230,7 +254,8 @@ trodo.track_llm_call(
230
254
  model='gemini-2.5-flash', provider='google',
231
255
  input_tokens=resp['usageMetadata']['promptTokenCount'],
232
256
  output_tokens=resp['usageMetadata']['candidatesTokenCount'],
233
- prompt=body, completion=resp,
257
+ prompt=body['messages'], # the chat-message list sent to the model
258
+ completion=resp,
234
259
  )
235
260
  ```
236
261
 
@@ -382,6 +407,44 @@ Runnable scenarios that double as integration tests live in
382
407
 
383
408
  ---
384
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
+
385
448
  ## Agent Analytics (legacy event-based API)
386
449
 
387
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.10.11"
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() == []
@@ -47,3 +47,25 @@ def test_wrap_agent_records_error_on_exception(processor, http):
47
47
  run_payload = http.run_ingest[0]["run"]
48
48
  assert run_payload["status"] == "error"
49
49
  assert "kaboom" in run_payload["error_summary"]
50
+
51
+
52
+ def test_set_input_passes_chat_message_list_through(processor, http):
53
+ messages = [
54
+ {"role": "system", "content": "be terse"},
55
+ {"role": "context", "content": ["doc chunk"]},
56
+ {"role": "user", "content": "q1"},
57
+ {"role": "assistant", "content": None, "tool_calls": [{"id": "c1", "function": {"name": "f", "arguments": "{}"}}]},
58
+ {"role": "tool", "content": '{"ok": true}', "tool_call_id": "c1"},
59
+ {"role": "user", "content": "q2"},
60
+ ]
61
+ with wrap_agent(
62
+ processor=processor,
63
+ team_site_id="site-x",
64
+ agent_name="chat",
65
+ ) as run:
66
+ run.set_input(messages)
67
+ run.set_output("done")
68
+
69
+ run_payload = http.run_ingest[0]["run"]
70
+ # The list must arrive as-is — no JSON stringification, no envelope.
71
+ assert run_payload["input"] == messages
@@ -19,7 +19,8 @@ Raw-HTTP LLM caller (no OTel integration for your client):
19
19
  model='gemini-2.5-flash', provider='google',
20
20
  input_tokens=resp['usageMetadata']['promptTokenCount'],
21
21
  output_tokens=resp['usageMetadata']['candidatesTokenCount'],
22
- prompt=body, completion=resp,
22
+ prompt=body['messages'], # the chat-message list sent to the model
23
+ completion=resp,
23
24
  )
24
25
 
25
26
  Custom tool:
@@ -40,13 +41,21 @@ Downstream microservice (join the caller's run instead of making a new one):
40
41
 
41
42
  from __future__ import annotations
42
43
 
43
- __version__ = "2.10.11"
44
+ __version__ = "2.12.0"
44
45
 
45
46
  from typing import Any, Callable, Dict, List, Optional, Union
46
47
 
47
48
  from .client import TrodoClient
48
49
  from .user_context import UserContext
49
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
+ )
50
59
  from .otel.wrap_agent import (
51
60
  wrap_agent as _wrap_agent_ctx,
52
61
  span as _span_ctx,
@@ -99,6 +108,17 @@ __all__ = [
99
108
  "propagation_headers",
100
109
  "current_run_id",
101
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",
102
122
  ]
103
123
 
104
124
  # ============================================================================
@@ -248,6 +268,82 @@ def reset(distinct_id: str) -> ResetResult:
248
268
  return _get_client().reset(distinct_id)
249
269
 
250
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
+
251
347
  def enable_auto_events() -> None:
252
348
  _get_client().enable_auto_events()
253
349