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.
- {trodo_python-2.10.11 → trodo_python-2.12.0}/PKG-INFO +65 -2
- {trodo_python-2.10.11 → trodo_python-2.12.0}/README.md +64 -1
- {trodo_python-2.10.11 → trodo_python-2.12.0}/pyproject.toml +1 -1
- trodo_python-2.12.0/tests/test_prompts.py +325 -0
- {trodo_python-2.10.11 → trodo_python-2.12.0}/tests/test_wrap_agent_unchanged.py +22 -0
- {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo/__init__.py +98 -2
- {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo/api/http_client.py +53 -0
- {trodo_python-2.10.11 → 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.10.11 → trodo_python-2.12.0}/trodo/otel/helpers.py +13 -1
- {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo/otel/wrap_agent.py +16 -5
- 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.10.11 → trodo_python-2.12.0}/trodo_python.egg-info/PKG-INFO +65 -2
- {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo_python.egg-info/SOURCES.txt +6 -1
- trodo_python-2.10.11/tests/test_anon_distinct_id 2.py +0 -100
- {trodo_python-2.10.11 → trodo_python-2.12.0}/setup.cfg +0 -0
- {trodo_python-2.10.11 → trodo_python-2.12.0}/tests/test_anon_distinct_id.py +0 -0
- {trodo_python-2.10.11 → trodo_python-2.12.0}/tests/test_auto_instrument_fixes.py +0 -0
- {trodo_python-2.10.11 → trodo_python-2.12.0}/tests/test_cross_process_session.py +0 -0
- {trodo_python-2.10.11 → trodo_python-2.12.0}/tests/test_end_run.py +0 -0
- {trodo_python-2.10.11 → trodo_python-2.12.0}/tests/test_error_enrichment.py +0 -0
- {trodo_python-2.10.11 → trodo_python-2.12.0}/tests/test_llm_usage_cost.py +0 -0
- {trodo_python-2.10.11 → trodo_python-2.12.0}/tests/test_processor_methods.py +0 -0
- {trodo_python-2.10.11 → trodo_python-2.12.0}/tests/test_register_otel.py +0 -0
- {trodo_python-2.10.11 → trodo_python-2.12.0}/tests/test_start_run.py +0 -0
- {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo/api/__init__.py +0 -0
- {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo/api/async_client.py +0 -0
- {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo/api/endpoints.py +0 -0
- {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo/auto/__init__.py +0 -0
- {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo/auto/auto_event_manager.py +0 -0
- {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo/managers/__init__.py +0 -0
- {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo/managers/group_manager.py +0 -0
- {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo/managers/people_manager.py +0 -0
- {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo/otel/__init__.py +0 -0
- {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo/otel/auto_instrument.py +0 -0
- {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo/otel/context.py +0 -0
- {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo/otel/processor.py +0 -0
- {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo/otel/register.py +0 -0
- {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo/otel/transport.py +0 -0
- {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo/queue/__init__.py +0 -0
- {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo/queue/batch_flusher.py +0 -0
- {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo/queue/event_queue.py +0 -0
- {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo/session/__init__.py +0 -0
- {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo/session/server_session.py +0 -0
- {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo/session/session_manager.py +0 -0
- {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo/types.py +0 -0
- {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo/user_context.py +0 -0
- {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo_python.egg-info/dependency_links.txt +0 -0
- {trodo_python-2.10.11 → trodo_python-2.12.0}/trodo_python.egg-info/requires.txt +0 -0
- {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.
|
|
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,
|
|
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,
|
|
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
|
|
@@ -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,
|
|
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.
|
|
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
|
|