trodo-python 2.10.11__py3-none-any.whl → 2.12.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- trodo/__init__.py +98 -2
- trodo/api/http_client.py +53 -0
- trodo/client.py +9 -0
- trodo/managers/prompt_manager.py +286 -0
- trodo/otel/helpers.py +13 -1
- trodo/otel/wrap_agent.py +16 -5
- trodo/prompts/__init__.py +37 -0
- trodo/prompts/compile.py +181 -0
- trodo/prompts/template.py +280 -0
- trodo/prompts/types.py +87 -0
- {trodo_python-2.10.11.dist-info → trodo_python-2.12.0.dist-info}/METADATA +65 -2
- {trodo_python-2.10.11.dist-info → trodo_python-2.12.0.dist-info}/RECORD +14 -9
- {trodo_python-2.10.11.dist-info → trodo_python-2.12.0.dist-info}/WHEEL +0 -0
- {trodo_python-2.10.11.dist-info → trodo_python-2.12.0.dist-info}/top_level.txt +0 -0
trodo/__init__.py
CHANGED
|
@@ -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
|
|
trodo/api/http_client.py
CHANGED
|
@@ -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
|
|
trodo/client.py
CHANGED
|
@@ -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
|
# --------------------------------------------------------------------------
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""Prompt management — read managed prompts from the Trodo registry.
|
|
2
|
+
|
|
3
|
+
Prompts are authored and versioned in the Trodo dashboard; the SDK fetches them
|
|
4
|
+
at runtime by name + optional label/version so application code never hard-codes
|
|
5
|
+
prompt text. A deploy label (e.g. ``production``) points at one version, so you
|
|
6
|
+
ship a new prompt by moving the label rather than redeploying.
|
|
7
|
+
|
|
8
|
+
``compile()`` returns a structured :class:`CompiledPrompt` — ``{messages, model,
|
|
9
|
+
tools, response_format}``, not a string. A string has nowhere to put model
|
|
10
|
+
config or tools, which is why every product that returns one cannot carry them.
|
|
11
|
+
|
|
12
|
+
Mirrors ``sdks/trodo-node-sdk/src/managers/PromptManager.ts`` test-for-test.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
from typing import Any, Callable, Dict, List, Optional, Set
|
|
20
|
+
|
|
21
|
+
from ..prompts.compile import CompileError, compile_prompt
|
|
22
|
+
from ..prompts.template import TemplateError, render
|
|
23
|
+
from ..prompts.types import CompiledPrompt, ManagedPrompt, PromptSummary, PromptVariable
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"PromptManager",
|
|
27
|
+
"ManagedPrompt",
|
|
28
|
+
"PromptSummary",
|
|
29
|
+
"PromptVariable",
|
|
30
|
+
"CompiledPrompt",
|
|
31
|
+
"CompileError",
|
|
32
|
+
"TemplateError",
|
|
33
|
+
"compile_prompt",
|
|
34
|
+
"render",
|
|
35
|
+
"DEFAULT_TTL_SECONDS",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
DEFAULT_TTL_SECONDS = 60.0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _cache_key(name: str, version: Optional[int], label: Optional[str]) -> str:
|
|
42
|
+
# Resolution happens server-side on every fetch; the client only caches
|
|
43
|
+
# under whatever selector was asked for. So a label flip propagates within
|
|
44
|
+
# one TTL without the client knowing anything about labels.
|
|
45
|
+
if version is not None:
|
|
46
|
+
return f"{name}::version:{version}"
|
|
47
|
+
if label:
|
|
48
|
+
return f"{name}::label:{label}"
|
|
49
|
+
return f"{name}::label:production"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _to_variables(raw: Any) -> List[PromptVariable]:
|
|
53
|
+
out: List[PromptVariable] = []
|
|
54
|
+
for v in raw or []:
|
|
55
|
+
if isinstance(v, PromptVariable):
|
|
56
|
+
out.append(v)
|
|
57
|
+
elif isinstance(v, dict) and isinstance(v.get("name"), str):
|
|
58
|
+
out.append(
|
|
59
|
+
PromptVariable(
|
|
60
|
+
name=v["name"],
|
|
61
|
+
type=v.get("type") or "string",
|
|
62
|
+
required=bool(v.get("required")),
|
|
63
|
+
default=v.get("default"),
|
|
64
|
+
description=v.get("description"),
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
return out
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _to_prompt(raw: Dict[str, Any], is_fallback: bool = False) -> ManagedPrompt:
|
|
71
|
+
return ManagedPrompt(
|
|
72
|
+
name=str(raw.get("name") or ""),
|
|
73
|
+
description=raw.get("description"),
|
|
74
|
+
tags=list(raw.get("tags") or []),
|
|
75
|
+
version=int(raw.get("version") or 0),
|
|
76
|
+
labels=list(raw.get("labels") or []),
|
|
77
|
+
messages=list(raw.get("messages") or []),
|
|
78
|
+
model_config=dict(raw.get("model_config") or {}),
|
|
79
|
+
tools=list(raw.get("tools") or []),
|
|
80
|
+
response_format=raw.get("response_format"),
|
|
81
|
+
variables=_to_variables(raw.get("variables")),
|
|
82
|
+
updated_at=raw.get("updated_at") or raw.get("updatedAt"),
|
|
83
|
+
is_fallback=is_fallback,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class _Cache:
|
|
88
|
+
"""Prompt cache with stale-while-revalidate.
|
|
89
|
+
|
|
90
|
+
An LLM app must never hard-fail because the prompt service blinked. The
|
|
91
|
+
ladder, in order:
|
|
92
|
+
|
|
93
|
+
fresh cache -> stale cache -> caller's fallback -> raise
|
|
94
|
+
|
|
95
|
+
A prompt fetch sits on the hot path of every request an app makes. Without
|
|
96
|
+
this, adding Trodo prompt management would make an app strictly *less*
|
|
97
|
+
reliable than hardcoding the string — which is a good reason not to adopt it.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
def __init__(self) -> None:
|
|
101
|
+
self._store: Dict[str, Any] = {}
|
|
102
|
+
self._expires: Dict[str, float] = {}
|
|
103
|
+
self._refreshing: Set[str] = set()
|
|
104
|
+
self._lock = threading.Lock()
|
|
105
|
+
|
|
106
|
+
def get_fresh(self, key: str) -> Optional[Any]:
|
|
107
|
+
with self._lock:
|
|
108
|
+
if key in self._store and self._expires.get(key, 0.0) > time.time():
|
|
109
|
+
return self._store[key]
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
def get_stale(self, key: str) -> Optional[Any]:
|
|
113
|
+
with self._lock:
|
|
114
|
+
return self._store.get(key)
|
|
115
|
+
|
|
116
|
+
def set(self, key: str, value: Any, ttl: float) -> None:
|
|
117
|
+
with self._lock:
|
|
118
|
+
self._store[key] = value
|
|
119
|
+
self._expires[key] = time.time() + ttl
|
|
120
|
+
|
|
121
|
+
def revalidate(self, key: str, fetcher: Callable[[], Any], ttl: float) -> None:
|
|
122
|
+
with self._lock:
|
|
123
|
+
if key in self._refreshing:
|
|
124
|
+
return
|
|
125
|
+
self._refreshing.add(key)
|
|
126
|
+
|
|
127
|
+
def _run() -> None:
|
|
128
|
+
try:
|
|
129
|
+
self.set(key, fetcher(), ttl)
|
|
130
|
+
except Exception: # noqa: BLE001
|
|
131
|
+
# Deliberately swallowed. The caller already has a usable stale
|
|
132
|
+
# value; a background refresh failing is not their problem and
|
|
133
|
+
# must never surface. It retries on the next request.
|
|
134
|
+
pass
|
|
135
|
+
finally:
|
|
136
|
+
with self._lock:
|
|
137
|
+
self._refreshing.discard(key)
|
|
138
|
+
|
|
139
|
+
threading.Thread(target=_run, daemon=True).start()
|
|
140
|
+
|
|
141
|
+
def clear(self) -> None:
|
|
142
|
+
with self._lock:
|
|
143
|
+
self._store.clear()
|
|
144
|
+
self._expires.clear()
|
|
145
|
+
self._refreshing.clear()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _with_retry(fn: Callable[[], Any], max_retries: int) -> Any:
|
|
149
|
+
"""Retry with exponential backoff, capped. Mirrors the Node SDK."""
|
|
150
|
+
attempts = max(0, min(max_retries, 4))
|
|
151
|
+
last: Optional[BaseException] = None
|
|
152
|
+
for i in range(attempts + 1):
|
|
153
|
+
try:
|
|
154
|
+
return fn()
|
|
155
|
+
except Exception as e: # noqa: BLE001
|
|
156
|
+
last = e
|
|
157
|
+
if i == attempts:
|
|
158
|
+
break
|
|
159
|
+
time.sleep(min(2**i * 0.2, 10.0))
|
|
160
|
+
assert last is not None
|
|
161
|
+
raise last
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class PromptManager:
|
|
165
|
+
"""Read-only access to the team's managed prompt registry."""
|
|
166
|
+
|
|
167
|
+
def __init__(self, http_client: Any) -> None:
|
|
168
|
+
self._http = http_client
|
|
169
|
+
self._cache = _Cache()
|
|
170
|
+
|
|
171
|
+
def get(
|
|
172
|
+
self,
|
|
173
|
+
name: str,
|
|
174
|
+
label: Optional[str] = None,
|
|
175
|
+
version: Optional[int] = None,
|
|
176
|
+
cache_ttl_seconds: Optional[float] = None,
|
|
177
|
+
fallback: Optional[Dict[str, Any]] = None,
|
|
178
|
+
max_retries: int = 2,
|
|
179
|
+
) -> ManagedPrompt:
|
|
180
|
+
"""Fetch a managed prompt by name.
|
|
181
|
+
|
|
182
|
+
Without arguments you get the version labelled ``production``; pass
|
|
183
|
+
``label`` to follow a different deploy label, or ``version`` to pin
|
|
184
|
+
exactly.
|
|
185
|
+
|
|
186
|
+
Availability ladder — fresh cache -> stale cache -> ``fallback`` ->
|
|
187
|
+
raise. A prompt fetch is on your hot path, so a Trodo outage degrades
|
|
188
|
+
rather than takes your app down. Check ``prompt.is_fallback`` to detect
|
|
189
|
+
the last rung. Pass ``cache_ttl_seconds=0`` to disable caching (handy in
|
|
190
|
+
development).
|
|
191
|
+
|
|
192
|
+
:raises ValueError: if *name* is empty, or both ``label`` and ``version``
|
|
193
|
+
:raises LookupError: if the prompt cannot be fetched and nothing is
|
|
194
|
+
cached and no ``fallback`` was given
|
|
195
|
+
"""
|
|
196
|
+
if not name:
|
|
197
|
+
raise ValueError("trodo: get_prompt(name) requires a name")
|
|
198
|
+
if version is not None and label:
|
|
199
|
+
raise ValueError("trodo: pass either version or label, not both")
|
|
200
|
+
|
|
201
|
+
key = _cache_key(name, version, label)
|
|
202
|
+
ttl = DEFAULT_TTL_SECONDS if cache_ttl_seconds is None else float(cache_ttl_seconds)
|
|
203
|
+
|
|
204
|
+
def fetcher() -> Dict[str, Any]:
|
|
205
|
+
res = _with_retry(
|
|
206
|
+
lambda: self._http.get_prompt(name, label=label, version=version),
|
|
207
|
+
max_retries,
|
|
208
|
+
)
|
|
209
|
+
if not res or res.get("__error") or not res.get("prompt"):
|
|
210
|
+
status = res.get("status") if isinstance(res, dict) else None
|
|
211
|
+
detail = res.get("error") if isinstance(res, dict) else None
|
|
212
|
+
raise LookupError(
|
|
213
|
+
f"trodo: could not fetch prompt {name!r}"
|
|
214
|
+
+ (f" (HTTP {status})" if status else "")
|
|
215
|
+
+ (f": {detail}" if detail else "")
|
|
216
|
+
)
|
|
217
|
+
return res["prompt"]
|
|
218
|
+
|
|
219
|
+
if ttl > 0:
|
|
220
|
+
fresh = self._cache.get_fresh(key)
|
|
221
|
+
if fresh is not None:
|
|
222
|
+
return _to_prompt(fresh)
|
|
223
|
+
|
|
224
|
+
stale = self._cache.get_stale(key)
|
|
225
|
+
if stale is not None:
|
|
226
|
+
# Serve immediately, refresh behind the caller's back. A slow or
|
|
227
|
+
# dead API costs latency on nobody's request.
|
|
228
|
+
self._cache.revalidate(key, fetcher, ttl)
|
|
229
|
+
return _to_prompt(stale)
|
|
230
|
+
|
|
231
|
+
try:
|
|
232
|
+
raw = fetcher()
|
|
233
|
+
if ttl > 0:
|
|
234
|
+
self._cache.set(key, raw, ttl)
|
|
235
|
+
return _to_prompt(raw)
|
|
236
|
+
except Exception:
|
|
237
|
+
stale = self._cache.get_stale(key)
|
|
238
|
+
if stale is not None:
|
|
239
|
+
return _to_prompt(stale)
|
|
240
|
+
if fallback:
|
|
241
|
+
return _to_prompt(
|
|
242
|
+
{
|
|
243
|
+
"name": name,
|
|
244
|
+
"version": 0,
|
|
245
|
+
"labels": [],
|
|
246
|
+
"tags": [],
|
|
247
|
+
"messages": fallback.get("messages") or [],
|
|
248
|
+
"model_config": fallback.get("model_config") or {},
|
|
249
|
+
"tools": [],
|
|
250
|
+
"response_format": None,
|
|
251
|
+
"variables": fallback.get("variables") or [],
|
|
252
|
+
},
|
|
253
|
+
is_fallback=True,
|
|
254
|
+
)
|
|
255
|
+
raise
|
|
256
|
+
|
|
257
|
+
def list(self) -> List[PromptSummary]:
|
|
258
|
+
"""List the prompts available to this site's team (names + labels)."""
|
|
259
|
+
res = self._http.list_prompts()
|
|
260
|
+
if not res or res.get("__error") or not isinstance(res.get("prompts"), list):
|
|
261
|
+
return []
|
|
262
|
+
return [
|
|
263
|
+
PromptSummary(
|
|
264
|
+
name=str(p.get("name") or ""),
|
|
265
|
+
description=p.get("description"),
|
|
266
|
+
version=int(p.get("version") or 0),
|
|
267
|
+
labels=list(p.get("labels") or []),
|
|
268
|
+
updated_at=p.get("updated_at") or p.get("updatedAt"),
|
|
269
|
+
)
|
|
270
|
+
for p in res["prompts"]
|
|
271
|
+
]
|
|
272
|
+
|
|
273
|
+
def compile(
|
|
274
|
+
self,
|
|
275
|
+
prompt: ManagedPrompt,
|
|
276
|
+
variables: Optional[Dict[str, Any]] = None,
|
|
277
|
+
**kwargs: Any,
|
|
278
|
+
) -> CompiledPrompt:
|
|
279
|
+
"""Compile a managed prompt with variable values."""
|
|
280
|
+
values = dict(variables or {})
|
|
281
|
+
values.update(kwargs)
|
|
282
|
+
return prompt.compile(values)
|
|
283
|
+
|
|
284
|
+
def clear_cache(self) -> None:
|
|
285
|
+
"""Drop everything cached. Mostly useful in tests."""
|
|
286
|
+
self._cache.clear()
|
trodo/otel/helpers.py
CHANGED
|
@@ -297,6 +297,12 @@ def llm(
|
|
|
297
297
|
fall back to scalar-only extraction, or ``extract_usage_map=lambda r: {..}``
|
|
298
298
|
to build the map yourself.
|
|
299
299
|
|
|
300
|
+
The wrapped function's arguments become the span input. For the AI-score
|
|
301
|
+
detectors to see per-role content, pass the model call's chat-message list
|
|
302
|
+
(``[{"role", "content"}, ...]`` — roles system/user/assistant/tool +
|
|
303
|
+
``context`` for RAG docs) so the span input is exactly what the model
|
|
304
|
+
receives.
|
|
305
|
+
|
|
300
306
|
Usage::
|
|
301
307
|
|
|
302
308
|
answer = trodo.llm(
|
|
@@ -470,12 +476,18 @@ def track_llm_call(
|
|
|
470
476
|
``usage_details`` map, or a raw provider ``usage`` object to auto-extract
|
|
471
477
|
from (e.g. ``resp['usage']`` or ``resp['usageMetadata']``).
|
|
472
478
|
|
|
479
|
+
``prompt`` — prefer the chat-message list you sent to the model
|
|
480
|
+
(``[{"role", "content"}, ...]``, roles system/user/assistant/tool +
|
|
481
|
+
``context`` for RAG docs, any order, multiple per role). The backend
|
|
482
|
+
embeds each role separately, which powers the AI-score detectors. A plain
|
|
483
|
+
string or dict is stored as one opaque input.
|
|
484
|
+
|
|
473
485
|
Usage:
|
|
474
486
|
resp = httpx.post(url, json=body).json()
|
|
475
487
|
trodo.track_llm_call(
|
|
476
488
|
model='claude-sonnet-4', provider='anthropic',
|
|
477
489
|
usage=resp['usage'], # cache fields captured automatically
|
|
478
|
-
prompt=body, completion=resp,
|
|
490
|
+
prompt=body['messages'], completion=resp,
|
|
479
491
|
)
|
|
480
492
|
"""
|
|
481
493
|
if get_active_context() is None:
|
trodo/otel/wrap_agent.py
CHANGED
|
@@ -29,7 +29,7 @@ import time
|
|
|
29
29
|
import traceback
|
|
30
30
|
import uuid
|
|
31
31
|
from datetime import datetime, timezone
|
|
32
|
-
from typing import Any, Callable, Dict, Optional, Union
|
|
32
|
+
from typing import Any, Callable, Dict, List, Optional, Union
|
|
33
33
|
|
|
34
34
|
from .context import ActiveSpanContext, get_active_context, run_with_context
|
|
35
35
|
from .processor import TrodoSpanProcessor, TrodoRun, TrodoSpan
|
|
@@ -149,16 +149,19 @@ def _resolve_error(handle, exc_type, exc, tb) -> Dict[str, Optional[str]]:
|
|
|
149
149
|
"status_code": None, "stack_trace": None, "level": None}
|
|
150
150
|
|
|
151
151
|
|
|
152
|
-
def _prepare_value(value: Any, max_len: int = _MAX_VALUE_LEN) -> Optional[Union[str, Dict[str, Any]]]:
|
|
152
|
+
def _prepare_value(value: Any, max_len: int = _MAX_VALUE_LEN) -> Optional[Union[str, Dict[str, Any], List[Any]]]:
|
|
153
153
|
"""Prepare a value for storage in the JSONB input/output column.
|
|
154
154
|
|
|
155
|
-
Dicts/lists pass through as-is (stored as JSONB objects/arrays).
|
|
156
|
-
|
|
155
|
+
Dicts/lists pass through as-is (stored as JSONB objects/arrays). For LLM
|
|
156
|
+
span inputs, prefer a chat-message list ``[{"role", "content"}, ...]``
|
|
157
|
+
(roles system/user/assistant/tool + ``context`` for RAG docs, any order,
|
|
158
|
+
multiple per role) — the backend embeds each role separately and the
|
|
159
|
+
AI-score detectors key on them. Strings are truncated at max_len.
|
|
157
160
|
Everything else is JSON-serialised then truncated.
|
|
158
161
|
"""
|
|
159
162
|
if value is None:
|
|
160
163
|
return None
|
|
161
|
-
if isinstance(value, dict):
|
|
164
|
+
if isinstance(value, (dict, list)):
|
|
162
165
|
return value
|
|
163
166
|
if isinstance(value, str):
|
|
164
167
|
return value[:max_len] if len(value) > max_len else value
|
|
@@ -248,6 +251,9 @@ class RunHandle:
|
|
|
248
251
|
self.error_type: Optional[str] = None
|
|
249
252
|
|
|
250
253
|
def set_input(self, value: Any) -> None:
|
|
254
|
+
"""Set the run input. Prefer a chat-message list
|
|
255
|
+
``[{"role": "user", "content": ...}, ...]`` — the backend embeds the
|
|
256
|
+
user messages as the run's semantic input."""
|
|
251
257
|
self.input = _prepare_value(value)
|
|
252
258
|
|
|
253
259
|
def set_output(self, value: Any) -> None:
|
|
@@ -337,6 +343,11 @@ class SpanHandle:
|
|
|
337
343
|
return self.error_message is not None or self.error_type is not None
|
|
338
344
|
|
|
339
345
|
def set_input(self, value: Any) -> None:
|
|
346
|
+
"""Set the span input. For LLM spans prefer a chat-message list
|
|
347
|
+
``[{"role", "content"}, ...]`` (roles system/user/assistant/tool +
|
|
348
|
+
``context`` for RAG docs, any order, multiple per role) — each role is
|
|
349
|
+
embedded separately and powers the AI-score detectors. Anything else
|
|
350
|
+
is stored as one opaque input."""
|
|
340
351
|
self.input = _prepare_value(value)
|
|
341
352
|
|
|
342
353
|
def set_output(self, value: Any) -> None:
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Managed prompt support: template rendering, compilation, and types."""
|
|
2
|
+
|
|
3
|
+
from .compile import CompileError, build_scope, compile_prompt
|
|
4
|
+
from .template import TemplateError, extract_names, parse, render, render_with_report
|
|
5
|
+
from .types import (
|
|
6
|
+
CompiledPrompt,
|
|
7
|
+
ContentBlock,
|
|
8
|
+
ManagedPrompt,
|
|
9
|
+
ModelConfig,
|
|
10
|
+
PromptMessage,
|
|
11
|
+
PromptNode,
|
|
12
|
+
PromptSummary,
|
|
13
|
+
PromptTool,
|
|
14
|
+
PromptVariable,
|
|
15
|
+
ResponseFormat,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"CompileError",
|
|
20
|
+
"CompiledPrompt",
|
|
21
|
+
"ContentBlock",
|
|
22
|
+
"ManagedPrompt",
|
|
23
|
+
"ModelConfig",
|
|
24
|
+
"PromptMessage",
|
|
25
|
+
"PromptNode",
|
|
26
|
+
"PromptSummary",
|
|
27
|
+
"PromptTool",
|
|
28
|
+
"PromptVariable",
|
|
29
|
+
"ResponseFormat",
|
|
30
|
+
"TemplateError",
|
|
31
|
+
"build_scope",
|
|
32
|
+
"compile_prompt",
|
|
33
|
+
"extract_names",
|
|
34
|
+
"parse",
|
|
35
|
+
"render",
|
|
36
|
+
"render_with_report",
|
|
37
|
+
]
|
trodo/prompts/compile.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Compile a managed prompt into a ready-to-send payload.
|
|
2
|
+
|
|
3
|
+
Mirrors ``backend/services/prompts/compile.js`` and the Node SDK's
|
|
4
|
+
``src/prompts/compile.ts`` exactly. The backend compiles for the playground and
|
|
5
|
+
the hosted ``/run`` path; this compiles in the caller's process. They must agree,
|
|
6
|
+
or the prompt you tested is not the prompt you shipped.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
from .template import render
|
|
14
|
+
from .types import CompiledPrompt, PromptVariable
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CompileError(Exception):
|
|
18
|
+
"""Raised when a prompt cannot be compiled with the supplied values.
|
|
19
|
+
|
|
20
|
+
Carries ``details`` — every problem found, not just the first, so a caller
|
|
21
|
+
can surface them all at once.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, message: str, details: Optional[List[str]] = None) -> None:
|
|
25
|
+
super().__init__(message)
|
|
26
|
+
self.details: List[str] = details or []
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _coerce(value: Any, type_: str, name: str, errors: List[str]) -> Any:
|
|
30
|
+
"""Coerce a caller value to the declared type.
|
|
31
|
+
|
|
32
|
+
Values arrive over HTTP and from application code, so ``"5"`` for a number
|
|
33
|
+
variable is normal, not an error — but ``"abc"`` is.
|
|
34
|
+
"""
|
|
35
|
+
if value is None:
|
|
36
|
+
return value
|
|
37
|
+
|
|
38
|
+
if type_ == "number":
|
|
39
|
+
if isinstance(value, bool):
|
|
40
|
+
errors.append(f"variable '{name}': expected a number, got {value!r}")
|
|
41
|
+
return value
|
|
42
|
+
if isinstance(value, (int, float)):
|
|
43
|
+
return value
|
|
44
|
+
try:
|
|
45
|
+
return float(value) if "." in str(value) else int(value)
|
|
46
|
+
except (TypeError, ValueError):
|
|
47
|
+
errors.append(f"variable '{name}': expected a number, got {value!r}")
|
|
48
|
+
return value
|
|
49
|
+
|
|
50
|
+
if type_ == "boolean":
|
|
51
|
+
if isinstance(value, bool):
|
|
52
|
+
return value
|
|
53
|
+
if value == "true":
|
|
54
|
+
return True
|
|
55
|
+
if value == "false":
|
|
56
|
+
return False
|
|
57
|
+
errors.append(f"variable '{name}': expected a boolean, got {value!r}")
|
|
58
|
+
return value
|
|
59
|
+
|
|
60
|
+
if type_ == "string":
|
|
61
|
+
return value if isinstance(value, str) else str(value)
|
|
62
|
+
|
|
63
|
+
if type_ == "messages":
|
|
64
|
+
if not isinstance(value, list):
|
|
65
|
+
errors.append(f"variable '{name}': expected an array of messages")
|
|
66
|
+
return value
|
|
67
|
+
|
|
68
|
+
return value
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def build_scope(
|
|
72
|
+
variables: Optional[List[PromptVariable]],
|
|
73
|
+
values: Optional[Dict[str, Any]],
|
|
74
|
+
) -> Dict[str, Any]:
|
|
75
|
+
"""Build the render scope from declarations + caller values.
|
|
76
|
+
|
|
77
|
+
Resolution order: caller value → declared default → required-and-absent
|
|
78
|
+
raises → optional-and-absent renders empty.
|
|
79
|
+
"""
|
|
80
|
+
errors: List[str] = []
|
|
81
|
+
scope: Dict[str, Any] = {}
|
|
82
|
+
declared = set()
|
|
83
|
+
values = values or {}
|
|
84
|
+
|
|
85
|
+
for v in variables or []:
|
|
86
|
+
name = getattr(v, "name", None) if not isinstance(v, dict) else v.get("name")
|
|
87
|
+
if not isinstance(name, str):
|
|
88
|
+
continue
|
|
89
|
+
declared.add(name)
|
|
90
|
+
|
|
91
|
+
if isinstance(v, dict):
|
|
92
|
+
type_ = v.get("type") or "string"
|
|
93
|
+
has_default = "default" in v and v.get("default") is not None
|
|
94
|
+
default = v.get("default")
|
|
95
|
+
else:
|
|
96
|
+
type_ = getattr(v, "type", None) or "string"
|
|
97
|
+
default = getattr(v, "default", None)
|
|
98
|
+
has_default = default is not None
|
|
99
|
+
|
|
100
|
+
has = name in values
|
|
101
|
+
value = values.get(name) if has else None
|
|
102
|
+
|
|
103
|
+
# Missing value -> the declared default, or empty. Nothing is
|
|
104
|
+
# "required": compile always succeeds with whatever the caller provides.
|
|
105
|
+
if not has or value is None:
|
|
106
|
+
value = default if has_default else ([] if type_ == "messages" else "")
|
|
107
|
+
|
|
108
|
+
scope[name] = _coerce(value, type_, name, errors)
|
|
109
|
+
|
|
110
|
+
# Passing something the prompt doesn't declare is nearly always a rename
|
|
111
|
+
# that didn't propagate. Fail loudly rather than render a prompt nobody
|
|
112
|
+
# intended.
|
|
113
|
+
for k in values:
|
|
114
|
+
if k not in declared:
|
|
115
|
+
errors.append(f"variable '{k}' is not declared on this prompt")
|
|
116
|
+
|
|
117
|
+
if errors:
|
|
118
|
+
raise CompileError("Cannot compile prompt: " + "; ".join(errors), errors)
|
|
119
|
+
return scope
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _render_block(block: Dict[str, Any], scope: Dict[str, Any]) -> Dict[str, Any]:
|
|
123
|
+
if block.get("type") == "text":
|
|
124
|
+
out = dict(block)
|
|
125
|
+
out["text"] = render(block.get("text"), scope, strict=True)
|
|
126
|
+
return out
|
|
127
|
+
return block
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _resolve_placeholder(
|
|
131
|
+
node: Dict[str, Any], scope: Dict[str, Any], errors: List[str]
|
|
132
|
+
) -> List[Dict[str, Any]]:
|
|
133
|
+
name = node.get("name")
|
|
134
|
+
value = scope.get(name)
|
|
135
|
+
if value is None:
|
|
136
|
+
return []
|
|
137
|
+
if not isinstance(value, list):
|
|
138
|
+
# Langfuse coerces a non-list into {"role": "NOT_GIVEN", ...} plus a
|
|
139
|
+
# warning, and that sentinel role then reaches the provider. Refuse.
|
|
140
|
+
errors.append(
|
|
141
|
+
f"placeholder '{name}': expected an array of messages, got {type(value).__name__}"
|
|
142
|
+
)
|
|
143
|
+
return []
|
|
144
|
+
for i, m in enumerate(value):
|
|
145
|
+
if not isinstance(m, dict) or not isinstance(m.get("role"), str):
|
|
146
|
+
errors.append(f"placeholder '{name}'[{i}]: each item must be a message with a role")
|
|
147
|
+
return value
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def compile_prompt(prompt: Any, values: Optional[Dict[str, Any]] = None) -> CompiledPrompt:
|
|
151
|
+
"""Compile *prompt* with *values*.
|
|
152
|
+
|
|
153
|
+
Returns a :class:`CompiledPrompt` — ``{messages, model, tools,
|
|
154
|
+
response_format}``, not a string. A string has nowhere to put model config
|
|
155
|
+
or tools, which is why every product that returns one cannot carry them.
|
|
156
|
+
"""
|
|
157
|
+
get = (lambda k, d=None: prompt.get(k, d)) if isinstance(prompt, dict) else (
|
|
158
|
+
lambda k, d=None: getattr(prompt, k, d)
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
scope = build_scope(get("variables") or [], values or {})
|
|
162
|
+
errors: List[str] = []
|
|
163
|
+
out: List[Dict[str, Any]] = []
|
|
164
|
+
|
|
165
|
+
for node in get("messages") or []:
|
|
166
|
+
if isinstance(node, dict) and node.get("type") == "placeholder":
|
|
167
|
+
out.extend(_resolve_placeholder(node, scope, errors))
|
|
168
|
+
continue
|
|
169
|
+
msg = dict(node)
|
|
170
|
+
msg["content"] = [_render_block(b, scope) for b in (node.get("content") or [])]
|
|
171
|
+
out.append(msg)
|
|
172
|
+
|
|
173
|
+
if errors:
|
|
174
|
+
raise CompileError("Cannot compile prompt: " + "; ".join(errors), errors)
|
|
175
|
+
|
|
176
|
+
return CompiledPrompt(
|
|
177
|
+
messages=out,
|
|
178
|
+
model=get("model_config") or {},
|
|
179
|
+
tools=get("tools") or [],
|
|
180
|
+
response_format=get("response_format"),
|
|
181
|
+
)
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Trodo prompt template engine — Python SDK port.
|
|
2
|
+
|
|
3
|
+
MUST stay byte-identical to:
|
|
4
|
+
backend/services/prompts/template.js (source of truth)
|
|
5
|
+
sdks/trodo-node-sdk/src/prompts/template.ts
|
|
6
|
+
|
|
7
|
+
All three are pinned by ``backend/services/prompts/template.vectors.json``,
|
|
8
|
+
which this package's tests load directly. If you change behaviour here without
|
|
9
|
+
adding a vector and updating the other two, the suites will disagree — which is
|
|
10
|
+
the point. Divergence between what the playground renders and what the SDK
|
|
11
|
+
renders is the single worst bug this feature can have.
|
|
12
|
+
|
|
13
|
+
Zero dependencies, deliberately: this SDK ships with only ``requests`` and that
|
|
14
|
+
is a feature for something embedded in customer apps. Notably we do NOT use
|
|
15
|
+
jinja2 here — it has no faithful JavaScript implementation, and cross-language
|
|
16
|
+
fidelity matters more than expressiveness. See docs/prompt-management-v2-plan.md §5.
|
|
17
|
+
|
|
18
|
+
Supported:
|
|
19
|
+
{{name}} interpolate (raw — we emit prompts, not HTML)
|
|
20
|
+
{{a.b.c}} dot-path resolution
|
|
21
|
+
{{#name}}...{{/name}} section: truthy renders once, list renders per item
|
|
22
|
+
{{^name}}...{{/name}} inverted section
|
|
23
|
+
{{.}} current item inside a list section
|
|
24
|
+
{{! comment }} comment
|
|
25
|
+
|
|
26
|
+
Not supported (raises, never silently ignored): partials, lambdas, set-delimiters.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
import re
|
|
33
|
+
from typing import Any, Dict, List, Optional, Sequence, Set, Tuple
|
|
34
|
+
|
|
35
|
+
_OPEN = "{{"
|
|
36
|
+
_CLOSE = "}}"
|
|
37
|
+
|
|
38
|
+
_NAME_RE = re.compile(r"^(\.|[A-Za-z_$][A-Za-z0-9_$]*(\.[A-Za-z_$][A-Za-z0-9_$]*)*)$")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TemplateError(Exception):
|
|
42
|
+
"""Raised for malformed templates and (in strict mode) unknown variables."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# Tokens are dicts to mirror the JS ports exactly:
|
|
46
|
+
# {"t": "text", "v": str}
|
|
47
|
+
# {"t": "var", "v": str}
|
|
48
|
+
# {"t": "section", "v": str, "inverted": bool, "children": [...]}
|
|
49
|
+
Token = Dict[str, Any]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _assert_name(name: str, pos: int) -> None:
|
|
53
|
+
if not _NAME_RE.match(name):
|
|
54
|
+
raise TemplateError(f"Invalid variable name '{name}' at position {pos}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def parse(template: Optional[str]) -> List[Token]:
|
|
58
|
+
"""Parse a template into a token tree."""
|
|
59
|
+
src = "" if template is None else str(template)
|
|
60
|
+
root: List[Token] = []
|
|
61
|
+
stack: List[Tuple[Optional[str], List[Token]]] = [(None, root)]
|
|
62
|
+
i = 0
|
|
63
|
+
|
|
64
|
+
def push(tok: Token) -> None:
|
|
65
|
+
stack[-1][1].append(tok)
|
|
66
|
+
|
|
67
|
+
while i < len(src):
|
|
68
|
+
open_at = src.find(_OPEN, i)
|
|
69
|
+
if open_at == -1:
|
|
70
|
+
if i < len(src):
|
|
71
|
+
push({"t": "text", "v": src[i:]})
|
|
72
|
+
break
|
|
73
|
+
if open_at > i:
|
|
74
|
+
push({"t": "text", "v": src[i:open_at]})
|
|
75
|
+
|
|
76
|
+
close_at = src.find(_CLOSE, open_at + len(_OPEN))
|
|
77
|
+
if close_at == -1:
|
|
78
|
+
raise TemplateError(f"Unclosed '{{{{' at position {open_at}")
|
|
79
|
+
|
|
80
|
+
body = src[open_at + len(_OPEN) : close_at].strip()
|
|
81
|
+
i = close_at + len(_CLOSE)
|
|
82
|
+
|
|
83
|
+
if not body:
|
|
84
|
+
raise TemplateError(f"Empty tag '{{{{}}}}' at position {open_at}")
|
|
85
|
+
|
|
86
|
+
sigil = body[0]
|
|
87
|
+
rest = body[1:].strip()
|
|
88
|
+
|
|
89
|
+
if sigil == "!":
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
if sigil in (">", "<"):
|
|
93
|
+
raise TemplateError(
|
|
94
|
+
f"Partials ({{{{>{rest}}}}}) are not supported. Inline the content instead."
|
|
95
|
+
)
|
|
96
|
+
if sigil == "=":
|
|
97
|
+
raise TemplateError("Set-delimiter tags ({{=...=}}) are not supported.")
|
|
98
|
+
if sigil == "&":
|
|
99
|
+
raise TemplateError(
|
|
100
|
+
f"Unescaped tags ({{{{&{rest}}}}}) are not supported: "
|
|
101
|
+
f"{{{{{rest}}}}} is already raw."
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
if sigil in ("#", "^"):
|
|
105
|
+
if not rest:
|
|
106
|
+
raise TemplateError(f"Section tag missing a name at position {open_at}")
|
|
107
|
+
_assert_name(rest, open_at)
|
|
108
|
+
tok: Token = {"t": "section", "v": rest, "inverted": sigil == "^", "children": []}
|
|
109
|
+
push(tok)
|
|
110
|
+
stack.append((rest, tok["children"]))
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
if sigil == "/":
|
|
114
|
+
top_name, _ = stack[-1]
|
|
115
|
+
if top_name is None:
|
|
116
|
+
raise TemplateError(f"Unexpected closing tag {{{{/{rest}}}}} — no open section")
|
|
117
|
+
if top_name != rest:
|
|
118
|
+
raise TemplateError(
|
|
119
|
+
f"Mismatched closing tag: expected {{{{/{top_name}}}}} "
|
|
120
|
+
f"but found {{{{/{rest}}}}}"
|
|
121
|
+
)
|
|
122
|
+
stack.pop()
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
_assert_name(body, open_at)
|
|
126
|
+
push({"t": "var", "v": body})
|
|
127
|
+
|
|
128
|
+
if len(stack) > 1:
|
|
129
|
+
raise TemplateError(f"Unclosed section {{{{#{stack[-1][0]}}}}}")
|
|
130
|
+
return root
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _lookup(path: str, scopes: Sequence[Any]) -> Tuple[bool, Any]:
|
|
134
|
+
"""Resolve a dot-path against a scope chain (innermost first).
|
|
135
|
+
|
|
136
|
+
This is the piece every competitor gets wrong: Langfuse's parser lexes
|
|
137
|
+
``{{user.name}}`` as a name but then does a flat key lookup, so the dot-path
|
|
138
|
+
only resolves if you literally pass a ``"user.name"`` key. We walk it.
|
|
139
|
+
"""
|
|
140
|
+
if path == ".":
|
|
141
|
+
return (True, scopes[0]) if scopes else (False, None)
|
|
142
|
+
|
|
143
|
+
parts = path.split(".")
|
|
144
|
+
for scope in scopes:
|
|
145
|
+
if not isinstance(scope, dict):
|
|
146
|
+
continue
|
|
147
|
+
if parts[0] not in scope:
|
|
148
|
+
continue
|
|
149
|
+
|
|
150
|
+
cur: Any = scope
|
|
151
|
+
ok = True
|
|
152
|
+
for part in parts:
|
|
153
|
+
if not isinstance(cur, dict) or part not in cur:
|
|
154
|
+
ok = False
|
|
155
|
+
break
|
|
156
|
+
cur = cur[part]
|
|
157
|
+
if ok:
|
|
158
|
+
return True, cur
|
|
159
|
+
return False, None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _stringify(value: Any) -> str:
|
|
163
|
+
if value is None:
|
|
164
|
+
return ""
|
|
165
|
+
if isinstance(value, str):
|
|
166
|
+
return value
|
|
167
|
+
if isinstance(value, bool):
|
|
168
|
+
# Must match JS: `true` / `false`, not Python's `True` / `False`.
|
|
169
|
+
return "true" if value else "false"
|
|
170
|
+
if isinstance(value, (int, float)):
|
|
171
|
+
# Match JS number formatting: 5.0 renders as "5", not "5.0".
|
|
172
|
+
if isinstance(value, float) and value.is_integer():
|
|
173
|
+
return str(int(value))
|
|
174
|
+
return str(value)
|
|
175
|
+
try:
|
|
176
|
+
return json.dumps(value, separators=(",", ":"))
|
|
177
|
+
except (TypeError, ValueError):
|
|
178
|
+
return str(value)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _is_falsy(value: Any) -> bool:
|
|
182
|
+
if value is None or value is False or value == "":
|
|
183
|
+
return True
|
|
184
|
+
if isinstance(value, (list, tuple)) and len(value) == 0:
|
|
185
|
+
return True
|
|
186
|
+
return False
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _render_tokens(tokens: List[Token], scopes: List[Any], strict: bool, missing: Set[str]) -> str:
|
|
190
|
+
out: List[str] = []
|
|
191
|
+
for tok in tokens:
|
|
192
|
+
kind = tok["t"]
|
|
193
|
+
|
|
194
|
+
if kind == "text":
|
|
195
|
+
out.append(tok["v"])
|
|
196
|
+
continue
|
|
197
|
+
|
|
198
|
+
if kind == "var":
|
|
199
|
+
found, value = _lookup(tok["v"], scopes)
|
|
200
|
+
if not found:
|
|
201
|
+
if strict:
|
|
202
|
+
raise TemplateError(
|
|
203
|
+
f"Unknown variable '{{{{{tok['v']}}}}}'. "
|
|
204
|
+
"Declare it on the prompt or pass a value."
|
|
205
|
+
)
|
|
206
|
+
missing.add(tok["v"])
|
|
207
|
+
continue
|
|
208
|
+
out.append(_stringify(value))
|
|
209
|
+
continue
|
|
210
|
+
|
|
211
|
+
# section
|
|
212
|
+
found, value = _lookup(tok["v"], scopes)
|
|
213
|
+
if not found and strict and not tok["inverted"]:
|
|
214
|
+
raise TemplateError(
|
|
215
|
+
f"Unknown section '{{{{#{tok['v']}}}}}'. "
|
|
216
|
+
"Declare it on the prompt or pass a value."
|
|
217
|
+
)
|
|
218
|
+
if not found:
|
|
219
|
+
missing.add(tok["v"])
|
|
220
|
+
|
|
221
|
+
falsy = _is_falsy(value)
|
|
222
|
+
if tok["inverted"]:
|
|
223
|
+
if falsy:
|
|
224
|
+
out.append(_render_tokens(tok["children"], scopes, strict, missing))
|
|
225
|
+
continue
|
|
226
|
+
if falsy:
|
|
227
|
+
continue
|
|
228
|
+
|
|
229
|
+
if isinstance(value, (list, tuple)):
|
|
230
|
+
for item in value:
|
|
231
|
+
out.append(_render_tokens(tok["children"], [item, *scopes], strict, missing))
|
|
232
|
+
elif isinstance(value, dict):
|
|
233
|
+
out.append(_render_tokens(tok["children"], [value, *scopes], strict, missing))
|
|
234
|
+
else:
|
|
235
|
+
out.append(_render_tokens(tok["children"], scopes, strict, missing))
|
|
236
|
+
|
|
237
|
+
return "".join(out)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def render(
|
|
241
|
+
template: Optional[str],
|
|
242
|
+
values: Optional[Dict[str, Any]] = None,
|
|
243
|
+
strict: bool = True,
|
|
244
|
+
) -> str:
|
|
245
|
+
"""Render *template* with *values*.
|
|
246
|
+
|
|
247
|
+
Strict by default: an unknown variable raises rather than leaving a literal
|
|
248
|
+
``{{typo}}`` in the text you send to a model.
|
|
249
|
+
"""
|
|
250
|
+
return _render_tokens(parse(template), [values or {}], strict, set())
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def render_with_report(
|
|
254
|
+
template: Optional[str],
|
|
255
|
+
values: Optional[Dict[str, Any]] = None,
|
|
256
|
+
strict: bool = False,
|
|
257
|
+
) -> Tuple[str, List[str]]:
|
|
258
|
+
"""Render, also reporting which names were missing. Returns (text, missing)."""
|
|
259
|
+
missing: Set[str] = set()
|
|
260
|
+
text = _render_tokens(parse(template), [values or {}], strict, missing)
|
|
261
|
+
return text, list(missing)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def extract_names(template: Optional[str]) -> List[str]:
|
|
265
|
+
"""Names referenced by a template, first-appearance order, de-duplicated."""
|
|
266
|
+
seen: List[str] = []
|
|
267
|
+
|
|
268
|
+
def walk(tokens: List[Token]) -> None:
|
|
269
|
+
for tok in tokens:
|
|
270
|
+
if tok["t"] == "text":
|
|
271
|
+
continue
|
|
272
|
+
if tok["v"] != ".":
|
|
273
|
+
root = tok["v"].split(".")[0]
|
|
274
|
+
if root not in seen:
|
|
275
|
+
seen.append(root)
|
|
276
|
+
if tok["t"] == "section":
|
|
277
|
+
walk(tok["children"])
|
|
278
|
+
|
|
279
|
+
walk(parse(template))
|
|
280
|
+
return seen
|
trodo/prompts/types.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Public types for managed prompts.
|
|
2
|
+
|
|
3
|
+
Mirrors the wire contract in ``backend/controllers/sdkPromptController.js`` and
|
|
4
|
+
the Node SDK's ``src/prompts/types.ts``. All three move together.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
# A message is a plain dict on the wire:
|
|
13
|
+
# {"role": "system", "content": [{"type": "text", "text": "..."}]}
|
|
14
|
+
# A placeholder — a slot where the caller injects a list of messages:
|
|
15
|
+
# {"type": "placeholder", "name": "chat_history"}
|
|
16
|
+
PromptMessage = Dict[str, Any]
|
|
17
|
+
PromptNode = Dict[str, Any]
|
|
18
|
+
ContentBlock = Dict[str, Any]
|
|
19
|
+
ModelConfig = Dict[str, Any]
|
|
20
|
+
PromptTool = Dict[str, Any]
|
|
21
|
+
ResponseFormat = Dict[str, Any]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class PromptVariable:
|
|
26
|
+
"""A declared variable.
|
|
27
|
+
|
|
28
|
+
Every competitor leaves variables undeclared and untyped — discovered by
|
|
29
|
+
regex, with no defaults and no required-ness. The declaration is what lets
|
|
30
|
+
``compile()`` fail before the model call rather than shipping a literal
|
|
31
|
+
``{{typo}}`` and finding out from the bill.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
name: str
|
|
35
|
+
type: str = "string"
|
|
36
|
+
required: bool = False
|
|
37
|
+
default: Any = None
|
|
38
|
+
description: Optional[str] = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class CompiledPrompt:
|
|
43
|
+
"""The result of compiling a prompt — ready to hand to a provider client."""
|
|
44
|
+
|
|
45
|
+
messages: List[PromptMessage]
|
|
46
|
+
model: ModelConfig = field(default_factory=dict)
|
|
47
|
+
tools: List[PromptTool] = field(default_factory=list)
|
|
48
|
+
response_format: Optional[ResponseFormat] = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class ManagedPrompt:
|
|
53
|
+
"""A managed prompt resolved from the Trodo registry."""
|
|
54
|
+
|
|
55
|
+
name: str
|
|
56
|
+
messages: List[PromptNode] = field(default_factory=list)
|
|
57
|
+
model_config: ModelConfig = field(default_factory=dict)
|
|
58
|
+
tools: List[PromptTool] = field(default_factory=list)
|
|
59
|
+
response_format: Optional[ResponseFormat] = None
|
|
60
|
+
variables: List[PromptVariable] = field(default_factory=list)
|
|
61
|
+
version: int = 0
|
|
62
|
+
labels: List[str] = field(default_factory=list)
|
|
63
|
+
tags: List[str] = field(default_factory=list)
|
|
64
|
+
description: Optional[str] = None
|
|
65
|
+
updated_at: Optional[str] = None
|
|
66
|
+
#: True when this came from the ``fallback`` argument because the API was
|
|
67
|
+
#: unreachable and nothing was cached.
|
|
68
|
+
is_fallback: bool = False
|
|
69
|
+
|
|
70
|
+
def compile(self, variables: Optional[Dict[str, Any]] = None, **kwargs: Any) -> CompiledPrompt:
|
|
71
|
+
"""Compile with variable values, as a dict or as keyword arguments."""
|
|
72
|
+
from .compile import compile_prompt # local import avoids a cycle
|
|
73
|
+
|
|
74
|
+
values = dict(variables or {})
|
|
75
|
+
values.update(kwargs)
|
|
76
|
+
return compile_prompt(self, values)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class PromptSummary:
|
|
81
|
+
"""Lightweight prompt entry returned by ``list_prompts()``."""
|
|
82
|
+
|
|
83
|
+
name: str
|
|
84
|
+
version: int = 0
|
|
85
|
+
labels: List[str] = field(default_factory=list)
|
|
86
|
+
description: Optional[str] = None
|
|
87
|
+
updated_at: Optional[str] = None
|
|
@@ -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
|
|
@@ -1,31 +1,36 @@
|
|
|
1
|
-
trodo/__init__.py,sha256=
|
|
2
|
-
trodo/client.py,sha256=
|
|
1
|
+
trodo/__init__.py,sha256=F5asTaDmKcWtk6NJ_DVr4wo1S4HoYMWFiiyqHHDNSvE,21167
|
|
2
|
+
trodo/client.py,sha256=z2q1HZKQNENycv9CRD0kOfwEhmz3Ti9_B-uPsqpvY3U,19454
|
|
3
3
|
trodo/types.py,sha256=eySgUvCXROG2TxtxgiU0MNr5iH0DEcduK8bmYtTKG44,3138
|
|
4
4
|
trodo/user_context.py,sha256=9la6azzwEanVmdP4ps_xMoufbeWVeIGU-M8ychmgajg,7859
|
|
5
5
|
trodo/api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
6
|
trodo/api/async_client.py,sha256=rZN4aJ2QiKyrHBK260bApCUB9JaMWU6BQtzoSJZh7xk,3408
|
|
7
7
|
trodo/api/endpoints.py,sha256=HKQ3d_Mxf0y4HwlHor0XkSAwUVj-4Xvv--rzE9njxjM,1027
|
|
8
|
-
trodo/api/http_client.py,sha256=
|
|
8
|
+
trodo/api/http_client.py,sha256=gVYVqn-M4Mjd0zTjx_Zsr246BL_xvIHOa2w8YlpVvD4,7638
|
|
9
9
|
trodo/auto/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
10
|
trodo/auto/auto_event_manager.py,sha256=cztuRsRkNoJE5R4NfSfTrTJTGl4jx2Yb-Ncy0aVAPo8,4247
|
|
11
11
|
trodo/managers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
12
|
trodo/managers/group_manager.py,sha256=ki3Se3qEoSZfREX63oeDeBmEfZF-ISHLE8azEtLg0tM,3542
|
|
13
13
|
trodo/managers/people_manager.py,sha256=mMVnx40Mlifx6NGgChvohC9ViK6dQu2mkXNHbV8pK1E,2882
|
|
14
|
+
trodo/managers/prompt_manager.py,sha256=Ub7rlg3dVVllr6qBf6sXLGvunnP_7FLeGEOal6sYFPY,10448
|
|
14
15
|
trodo/otel/__init__.py,sha256=yiRFXWUU45bAM2CV37XeO7zf1hmnmjufdP4XO50yEyE,624
|
|
15
16
|
trodo/otel/auto_instrument.py,sha256=hJesQMOOp86U66PampcGNINmBVvwVuU_WHVrbUMauug,20895
|
|
16
17
|
trodo/otel/context.py,sha256=iJ1rE42-SbO8VZHAxhIl2ZJXgNwLIVps5xLg8GKgfFc,1165
|
|
17
|
-
trodo/otel/helpers.py,sha256=
|
|
18
|
+
trodo/otel/helpers.py,sha256=XOMWcgZHaq5SQbkFxDaXPE4CDFjn01xjJmJ1vIxvwpw,20730
|
|
18
19
|
trodo/otel/processor.py,sha256=aqcTmzTw9cESgIp829pu_XCa5_dG_2MaeJNsqJZeqQU,7495
|
|
19
20
|
trodo/otel/register.py,sha256=bV_ePTfUvPugig2GZnylhzxi2QfoPWs96A9mGLPMrSQ,9387
|
|
20
21
|
trodo/otel/transport.py,sha256=hzZz8gwSMGJ8CxdijmLn1Ljt18owr9XTWy13DLbwYbw,2441
|
|
21
|
-
trodo/otel/wrap_agent.py,sha256=
|
|
22
|
+
trodo/otel/wrap_agent.py,sha256=_nFDhxPyl0RlNKj29cBNeRc_zAY5FUiHvTiNUWxTt0M,39049
|
|
23
|
+
trodo/prompts/__init__.py,sha256=yunNc8WkTSfEEkRR-NZWXAt_ZB3Ee2INRh-4RE7uHj4,806
|
|
24
|
+
trodo/prompts/compile.py,sha256=Jqy1yaSE1j931KIHP1opMpEEmBKHv-I4BUq-1xaNVWA,6352
|
|
25
|
+
trodo/prompts/template.py,sha256=obQiivPCR6LEdz7q1cby7uL25tYHYui4aoEaiUNqWfs,9357
|
|
26
|
+
trodo/prompts/types.py,sha256=iCNoJk6_kk0JvEblGsDm6c9lEdQW6ixOOv6aCG6IP-k,2924
|
|
22
27
|
trodo/queue/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
23
28
|
trodo/queue/batch_flusher.py,sha256=4Lg6T3Urwi9U0Q4FpFGPmjDYKg4ZliCTR-ND8BJvWaY,1298
|
|
24
29
|
trodo/queue/event_queue.py,sha256=EVFZrhlq_kwC3jJ2GK0wMhHISf9UzLCZNDnT_aZ2I2A,872
|
|
25
30
|
trodo/session/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
26
31
|
trodo/session/server_session.py,sha256=McsudEiq33XDq3nqxgzBcUvIjQxCMscwEuAPnYXrTjs,2136
|
|
27
32
|
trodo/session/session_manager.py,sha256=JrgH1VeicmtlxPR4dXEuJbxhi23OelkgwW3-9Slv80o,2525
|
|
28
|
-
trodo_python-2.
|
|
29
|
-
trodo_python-2.
|
|
30
|
-
trodo_python-2.
|
|
31
|
-
trodo_python-2.
|
|
33
|
+
trodo_python-2.12.0.dist-info/METADATA,sha256=pvC-YSCsk3GkCPdxnNGUkbgT-ZLBWIgO-MWLxqzzcrA,23115
|
|
34
|
+
trodo_python-2.12.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
35
|
+
trodo_python-2.12.0.dist-info/top_level.txt,sha256=VCQu1CJWFmNsqTs1YxMcw4Mq35Tc3z3uI9RwHEXAayQ,6
|
|
36
|
+
trodo_python-2.12.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|