trodo-python 2.19.0__tar.gz → 2.21.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.19.0 → trodo_python-2.21.0}/PKG-INFO +1 -1
- {trodo_python-2.19.0 → trodo_python-2.21.0}/pyproject.toml +1 -1
- {trodo_python-2.19.0 → trodo_python-2.21.0}/tests/test_error_enrichment.py +25 -0
- {trodo_python-2.19.0 → trodo_python-2.21.0}/tests/test_prompts.py +77 -21
- {trodo_python-2.19.0 → trodo_python-2.21.0}/trodo/__init__.py +1 -1
- {trodo_python-2.19.0 → trodo_python-2.21.0}/trodo/managers/prompt_manager.py +38 -3
- {trodo_python-2.19.0 → trodo_python-2.21.0}/trodo/otel/wrap_agent.py +22 -0
- {trodo_python-2.19.0 → trodo_python-2.21.0}/trodo/prompts/compile.py +23 -23
- {trodo_python-2.19.0 → trodo_python-2.21.0}/trodo/prompts/types.py +8 -14
- {trodo_python-2.19.0 → trodo_python-2.21.0}/trodo_python.egg-info/PKG-INFO +1 -1
- {trodo_python-2.19.0 → trodo_python-2.21.0}/README.md +0 -0
- {trodo_python-2.19.0 → trodo_python-2.21.0}/setup.cfg +0 -0
- {trodo_python-2.19.0 → trodo_python-2.21.0}/tests/test_anon_distinct_id.py +0 -0
- {trodo_python-2.19.0 → trodo_python-2.21.0}/tests/test_auto_instrument_fixes.py +0 -0
- {trodo_python-2.19.0 → trodo_python-2.21.0}/tests/test_cross_process_session.py +0 -0
- {trodo_python-2.19.0 → trodo_python-2.21.0}/tests/test_datasets.py +0 -0
- {trodo_python-2.19.0 → trodo_python-2.21.0}/tests/test_end_run.py +0 -0
- {trodo_python-2.19.0 → trodo_python-2.21.0}/tests/test_experiments.py +0 -0
- {trodo_python-2.19.0 → trodo_python-2.21.0}/tests/test_get_prompt_version_types.py +0 -0
- {trodo_python-2.19.0 → trodo_python-2.21.0}/tests/test_llm_usage_cost.py +0 -0
- {trodo_python-2.19.0 → trodo_python-2.21.0}/tests/test_multi_agent_shape.py +0 -0
- {trodo_python-2.19.0 → trodo_python-2.21.0}/tests/test_processor_methods.py +0 -0
- {trodo_python-2.19.0 → trodo_python-2.21.0}/tests/test_prompt_trace.py +0 -0
- {trodo_python-2.19.0 → trodo_python-2.21.0}/tests/test_register_otel.py +0 -0
- {trodo_python-2.19.0 → trodo_python-2.21.0}/tests/test_start_run.py +0 -0
- {trodo_python-2.19.0 → trodo_python-2.21.0}/tests/test_user_traits.py +0 -0
- {trodo_python-2.19.0 → trodo_python-2.21.0}/tests/test_wrap_agent_unchanged.py +0 -0
- {trodo_python-2.19.0 → trodo_python-2.21.0}/trodo/api/__init__.py +0 -0
- {trodo_python-2.19.0 → trodo_python-2.21.0}/trodo/api/async_client.py +0 -0
- {trodo_python-2.19.0 → trodo_python-2.21.0}/trodo/api/endpoints.py +0 -0
- {trodo_python-2.19.0 → trodo_python-2.21.0}/trodo/api/http_client.py +0 -0
- {trodo_python-2.19.0 → trodo_python-2.21.0}/trodo/auto/__init__.py +0 -0
- {trodo_python-2.19.0 → trodo_python-2.21.0}/trodo/auto/auto_event_manager.py +0 -0
- {trodo_python-2.19.0 → trodo_python-2.21.0}/trodo/client.py +0 -0
- {trodo_python-2.19.0 → trodo_python-2.21.0}/trodo/managers/__init__.py +0 -0
- {trodo_python-2.19.0 → trodo_python-2.21.0}/trodo/managers/dataset_manager.py +0 -0
- {trodo_python-2.19.0 → trodo_python-2.21.0}/trodo/managers/experiment_manager.py +0 -0
- {trodo_python-2.19.0 → trodo_python-2.21.0}/trodo/managers/group_manager.py +0 -0
- {trodo_python-2.19.0 → trodo_python-2.21.0}/trodo/managers/people_manager.py +0 -0
- {trodo_python-2.19.0 → trodo_python-2.21.0}/trodo/managers/user_manager.py +0 -0
- {trodo_python-2.19.0 → trodo_python-2.21.0}/trodo/otel/__init__.py +0 -0
- {trodo_python-2.19.0 → trodo_python-2.21.0}/trodo/otel/auto_instrument.py +0 -0
- {trodo_python-2.19.0 → trodo_python-2.21.0}/trodo/otel/context.py +0 -0
- {trodo_python-2.19.0 → trodo_python-2.21.0}/trodo/otel/helpers.py +0 -0
- {trodo_python-2.19.0 → trodo_python-2.21.0}/trodo/otel/processor.py +0 -0
- {trodo_python-2.19.0 → trodo_python-2.21.0}/trodo/otel/prompt_trace.py +0 -0
- {trodo_python-2.19.0 → trodo_python-2.21.0}/trodo/otel/register.py +0 -0
- {trodo_python-2.19.0 → trodo_python-2.21.0}/trodo/otel/transport.py +0 -0
- {trodo_python-2.19.0 → trodo_python-2.21.0}/trodo/prompts/__init__.py +0 -0
- {trodo_python-2.19.0 → trodo_python-2.21.0}/trodo/prompts/template.py +0 -0
- {trodo_python-2.19.0 → trodo_python-2.21.0}/trodo/queue/__init__.py +0 -0
- {trodo_python-2.19.0 → trodo_python-2.21.0}/trodo/queue/batch_flusher.py +0 -0
- {trodo_python-2.19.0 → trodo_python-2.21.0}/trodo/queue/event_queue.py +0 -0
- {trodo_python-2.19.0 → trodo_python-2.21.0}/trodo/session/__init__.py +0 -0
- {trodo_python-2.19.0 → trodo_python-2.21.0}/trodo/session/server_session.py +0 -0
- {trodo_python-2.19.0 → trodo_python-2.21.0}/trodo/session/session_manager.py +0 -0
- {trodo_python-2.19.0 → trodo_python-2.21.0}/trodo/types.py +0 -0
- {trodo_python-2.19.0 → trodo_python-2.21.0}/trodo/user_context.py +0 -0
- {trodo_python-2.19.0 → trodo_python-2.21.0}/trodo/util/__init__.py +0 -0
- {trodo_python-2.19.0 → trodo_python-2.21.0}/trodo/util/lru.py +0 -0
- {trodo_python-2.19.0 → trodo_python-2.21.0}/trodo_python.egg-info/SOURCES.txt +0 -0
- {trodo_python-2.19.0 → trodo_python-2.21.0}/trodo_python.egg-info/dependency_links.txt +0 -0
- {trodo_python-2.19.0 → trodo_python-2.21.0}/trodo_python.egg-info/requires.txt +0 -0
- {trodo_python-2.19.0 → trodo_python-2.21.0}/trodo_python.egg-info/top_level.txt +0 -0
|
@@ -70,6 +70,31 @@ def test_errored_span_and_run_carry_rich_error_fields(processor, http):
|
|
|
70
70
|
assert "rate limit exceeded" in run_payload["error_summary"]
|
|
71
71
|
|
|
72
72
|
|
|
73
|
+
def test_raised_exception_carries_the_recorded_run_id(processor, http):
|
|
74
|
+
# The run IS recorded when an agent raises; this is the JOIN -- the
|
|
75
|
+
# exception a developer's error tracker captures must reference it.
|
|
76
|
+
with pytest.raises(RateLimitError) as e:
|
|
77
|
+
with wrap_agent(processor=processor, team_site_id="site-x", agent_name="chat"):
|
|
78
|
+
raise RateLimitError("boom")
|
|
79
|
+
|
|
80
|
+
run_payload = http.run_ingest[0]["run"]
|
|
81
|
+
assert e.value.trodo_run_id == run_payload["run_id"]
|
|
82
|
+
assert run_payload["status"] == "error"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_slotted_exception_still_propagates(processor, http):
|
|
86
|
+
# An exception that can't take attributes must not crash the error path --
|
|
87
|
+
# a failure inside error handling is the one unforgivable bug here.
|
|
88
|
+
class Slotted(Exception):
|
|
89
|
+
__slots__ = ()
|
|
90
|
+
|
|
91
|
+
with pytest.raises(Slotted):
|
|
92
|
+
with wrap_agent(processor=processor, team_site_id="site-x", agent_name="chat"):
|
|
93
|
+
raise Slotted("rigid")
|
|
94
|
+
|
|
95
|
+
assert http.run_ingest[0]["run"]["status"] == "error"
|
|
96
|
+
|
|
97
|
+
|
|
73
98
|
def test_set_error_records_without_raising(processor, http):
|
|
74
99
|
with wrap_agent(processor=processor, team_site_id="site-x", agent_name="chat") as run:
|
|
75
100
|
with span("soft-step", kind="tool") as s:
|
|
@@ -86,7 +86,7 @@ PROMPT = {
|
|
|
86
86
|
"response_format": None,
|
|
87
87
|
"variables": [
|
|
88
88
|
{"name": "persona", "type": "string", "default": "a greeter"},
|
|
89
|
-
{"name": "who", "type": "string"
|
|
89
|
+
{"name": "who", "type": "string"},
|
|
90
90
|
],
|
|
91
91
|
}
|
|
92
92
|
|
|
@@ -106,41 +106,43 @@ def test_compile_carries_model_and_tools():
|
|
|
106
106
|
assert out.response_format is None
|
|
107
107
|
|
|
108
108
|
|
|
109
|
-
def
|
|
110
|
-
# `who`
|
|
111
|
-
#
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
def test_compile_treats_none_and_empty_string_as_no_value():
|
|
117
|
-
# Both render as nothing, the exact outcome `required` exists to stop.
|
|
118
|
-
with pytest.raises(CompileError, match="'who': required"):
|
|
119
|
-
compile_prompt(PROMPT, {"who": None})
|
|
120
|
-
with pytest.raises(CompileError, match="'who': required"):
|
|
121
|
-
compile_prompt(PROMPT, {"who": ""})
|
|
109
|
+
def test_compile_renders_missing_variable_empty():
|
|
110
|
+
# `who` has no value and no default. There is deliberately no way to make
|
|
111
|
+
# that a failure: the rule is caller value -> default -> empty, and it never
|
|
112
|
+
# needs a fourth case.
|
|
113
|
+
out = compile_prompt(PROMPT, {})
|
|
114
|
+
assert out.messages[1]["content"][0] == {"type": "text", "text": "Hello "}
|
|
122
115
|
|
|
123
116
|
|
|
124
|
-
def
|
|
125
|
-
#
|
|
126
|
-
|
|
127
|
-
optional = {**PROMPT, "variables": [
|
|
117
|
+
def test_compile_ignores_a_legacy_required_flag():
|
|
118
|
+
# Versions in the wild still carry it. It must stay inert.
|
|
119
|
+
legacy = {**PROMPT, "variables": [
|
|
128
120
|
{"name": "persona", "type": "string", "default": "a greeter"},
|
|
129
121
|
{"name": "who", "type": "string"},
|
|
130
122
|
]}
|
|
131
|
-
out = compile_prompt(
|
|
123
|
+
out = compile_prompt(legacy, {})
|
|
132
124
|
assert out.messages[1]["content"][0] == {"type": "text", "text": "Hello "}
|
|
133
125
|
|
|
134
126
|
|
|
135
|
-
def
|
|
127
|
+
def test_compile_applies_a_default_when_omitted():
|
|
136
128
|
with_default = {**PROMPT, "variables": [
|
|
137
129
|
{"name": "persona", "type": "string", "default": "a greeter"},
|
|
138
|
-
{"name": "who", "type": "string", "
|
|
130
|
+
{"name": "who", "type": "string", "default": "world"},
|
|
139
131
|
]}
|
|
140
132
|
out = compile_prompt(with_default, {})
|
|
141
133
|
assert out.messages[1]["content"][0] == {"type": "text", "text": "Hello world"}
|
|
142
134
|
|
|
143
135
|
|
|
136
|
+
def test_coercion_error_text_matches_node_byte_for_byte():
|
|
137
|
+
# Node formats the offending value with JSON.stringify; repr's single
|
|
138
|
+
# quotes were the ONE divergence a full cross-SDK parity run found. The
|
|
139
|
+
# double quotes here are the point of the test.
|
|
140
|
+
numeric = {**PROMPT, "variables": [{"name": "who", "type": "number"}]}
|
|
141
|
+
with pytest.raises(CompileError) as e:
|
|
142
|
+
compile_prompt(numeric, {"who": "not-a-number"})
|
|
143
|
+
assert 'variable \'who\': expected a number, got "not-a-number"' in str(e.value)
|
|
144
|
+
|
|
145
|
+
|
|
144
146
|
def test_compile_rejects_undeclared_variable():
|
|
145
147
|
# Passing a variable the prompt doesn't declare is the one variable mistake
|
|
146
148
|
# compile still catches (it's almost always a typo/rename).
|
|
@@ -226,6 +228,8 @@ class FakeHttp:
|
|
|
226
228
|
self.last = {"name": name, "label": label, "version": version}
|
|
227
229
|
if self.fail:
|
|
228
230
|
raise RuntimeError("API down")
|
|
231
|
+
if getattr(self, "error_body", None):
|
|
232
|
+
return dict(self.error_body)
|
|
229
233
|
return {"prompt": self._prompt}
|
|
230
234
|
|
|
231
235
|
def list_prompts(self):
|
|
@@ -297,6 +301,58 @@ def test_stale_cache_survives_an_outage():
|
|
|
297
301
|
assert mgr.get("greeter", cache_ttl_seconds=0.01).version == 2
|
|
298
302
|
|
|
299
303
|
|
|
304
|
+
def test_missing_prompt_with_fallback_serves_fallback():
|
|
305
|
+
# Deleting a prompt mid-deploy is exactly what the fallback exists for --
|
|
306
|
+
# plain prompt_not_found stays an availability failure.
|
|
307
|
+
http = FakeHttp()
|
|
308
|
+
http.error_body = {"__error": True, "status": 404, "error": "prompt_not_found"}
|
|
309
|
+
mgr = PromptManager(http)
|
|
310
|
+
p = mgr.get("gone", cache_ttl_seconds=0, max_retries=0,
|
|
311
|
+
fallback={"messages": [{"role": "user", "content": [{"type": "text", "text": "hi"}]}]})
|
|
312
|
+
assert p.is_fallback is True
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def test_bad_version_raises_through_the_fallback():
|
|
316
|
+
# A selector naming nothing is a CONFIG error, not an outage. Being quietly
|
|
317
|
+
# handed the fallback would hide the typo for as long as it ships.
|
|
318
|
+
http = FakeHttp()
|
|
319
|
+
http.error_body = {"__error": True, "status": 404, "error": "version_not_found"}
|
|
320
|
+
mgr = PromptManager(http)
|
|
321
|
+
with pytest.raises(LookupError, match="version 999 does not exist on prompt 'greeter'") as e:
|
|
322
|
+
mgr.get("greeter", version=999, cache_ttl_seconds=0, max_retries=0,
|
|
323
|
+
fallback={"messages": [{"role": "user", "content": [{"type": "text", "text": "hi"}]}]})
|
|
324
|
+
assert e.value.code == "version_not_found"
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def test_bad_label_raises_through_the_fallback():
|
|
328
|
+
http = FakeHttp()
|
|
329
|
+
http.error_body = {"__error": True, "status": 404, "error": "label_not_found"}
|
|
330
|
+
mgr = PromptManager(http)
|
|
331
|
+
with pytest.raises(LookupError) as e:
|
|
332
|
+
mgr.get("greeter", label="prod-ue", cache_ttl_seconds=0, max_retries=0,
|
|
333
|
+
fallback={"messages": [{"role": "user", "content": [{"type": "text", "text": "hi"}]}]})
|
|
334
|
+
assert e.value.code == "label_not_found"
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def test_versionless_prompt_says_what_to_do():
|
|
338
|
+
http = FakeHttp()
|
|
339
|
+
http.error_body = {"__error": True, "status": 404, "error": "no_versions"}
|
|
340
|
+
mgr = PromptManager(http)
|
|
341
|
+
with pytest.raises(LookupError, match="has no versions yet"):
|
|
342
|
+
mgr.get("empty", cache_ttl_seconds=0, max_retries=0)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def test_config_error_not_masked_by_stale_cache():
|
|
346
|
+
# First fetch succeeds and caches; the label is then deleted server-side.
|
|
347
|
+
http = FakeHttp(prompt=WIRE)
|
|
348
|
+
mgr = PromptManager(http)
|
|
349
|
+
assert mgr.get("greeter", label="production", cache_ttl_seconds=0).version == 2
|
|
350
|
+
http.error_body = {"__error": True, "status": 404, "error": "label_not_found"}
|
|
351
|
+
with pytest.raises(LookupError) as e:
|
|
352
|
+
mgr.get("greeter", label="production", cache_ttl_seconds=0, max_retries=0)
|
|
353
|
+
assert e.value.code == "label_not_found"
|
|
354
|
+
|
|
355
|
+
|
|
300
356
|
def test_fallback_used_when_api_down_and_nothing_cached():
|
|
301
357
|
mgr = PromptManager(FakeHttp(fail=True))
|
|
302
358
|
p = mgr.get(
|
|
@@ -38,6 +38,11 @@ __all__ = [
|
|
|
38
38
|
DEFAULT_TTL_SECONDS = 60.0
|
|
39
39
|
|
|
40
40
|
|
|
41
|
+
# 404 bodies that mean "your SELECTOR is wrong", not "the prompt is gone".
|
|
42
|
+
# Mirrors backend/models/prompt.js missReason -- the wire contract's error half.
|
|
43
|
+
_CONFIG_ERROR_CODES = frozenset({"version_not_found", "label_not_found", "no_versions"})
|
|
44
|
+
|
|
45
|
+
|
|
41
46
|
def _cache_key(name: str, version: Optional[Union[int, str]], label: Optional[str]) -> str:
|
|
42
47
|
# Resolution happens server-side on every fetch; the client only caches
|
|
43
48
|
# under whatever selector was asked for. So a label flip propagates within
|
|
@@ -56,10 +61,12 @@ def _to_variables(raw: Any) -> List[PromptVariable]:
|
|
|
56
61
|
out.append(v)
|
|
57
62
|
elif isinstance(v, dict) and isinstance(v.get("name"), str):
|
|
58
63
|
out.append(
|
|
64
|
+
# Named fields only — a stored version may carry keys this SDK
|
|
65
|
+
# no longer models (`required`, retired in 2.20.0), and fetching
|
|
66
|
+
# one must never raise.
|
|
59
67
|
PromptVariable(
|
|
60
68
|
name=v["name"],
|
|
61
69
|
type=v.get("type") or "string",
|
|
62
|
-
required=bool(v.get("required")),
|
|
63
70
|
default=v.get("default"),
|
|
64
71
|
description=v.get("description"),
|
|
65
72
|
)
|
|
@@ -195,7 +202,14 @@ class PromptManager:
|
|
|
195
202
|
Availability ladder — fresh cache -> stale cache -> ``fallback`` ->
|
|
196
203
|
raise. A prompt fetch is on your hot path, so a Trodo outage degrades
|
|
197
204
|
rather than takes your app down. Check ``prompt.is_fallback`` to detect
|
|
198
|
-
the last rung.
|
|
205
|
+
the last rung.
|
|
206
|
+
|
|
207
|
+
The ladder is for AVAILABILITY failures only. A selector that names
|
|
208
|
+
nothing — a ``version`` or ``label`` that doesn't exist on a prompt
|
|
209
|
+
that does — is a config error in your code and raises immediately
|
|
210
|
+
(``e.code`` is ``version_not_found`` | ``label_not_found`` |
|
|
211
|
+
``no_versions``), because being quietly handed the fallback would hide
|
|
212
|
+
the typo for as long as it ships. Pass ``cache_ttl_seconds=0`` to disable caching (handy in
|
|
199
213
|
development).
|
|
200
214
|
|
|
201
215
|
:raises ValueError: if *name* is empty, or both ``label`` and ``version``
|
|
@@ -222,6 +236,22 @@ class PromptManager:
|
|
|
222
236
|
if not res or res.get("__error") or not res.get("prompt"):
|
|
223
237
|
status = res.get("status") if isinstance(res, dict) else None
|
|
224
238
|
detail = res.get("error") if isinstance(res, dict) else None
|
|
239
|
+
# The server distinguishes a selector that names nothing from a
|
|
240
|
+
# prompt that is missing. The first is a CONFIG error -- your
|
|
241
|
+
# code asks for a version or label that does not exist -- and
|
|
242
|
+
# must throw through the availability ladder below rather than
|
|
243
|
+
# be masked by stale content or the fallback.
|
|
244
|
+
if detail in _CONFIG_ERROR_CODES:
|
|
245
|
+
if detail == "version_not_found":
|
|
246
|
+
what = f"version {version!r} does not exist on prompt {name!r}"
|
|
247
|
+
elif detail == "label_not_found":
|
|
248
|
+
what = f"label {label!r} does not exist on prompt {name!r}"
|
|
249
|
+
else:
|
|
250
|
+
what = f"prompt {name!r} has no versions yet -- save one in the dashboard"
|
|
251
|
+
err = LookupError(f"trodo: {what}" + (f" (HTTP {status})" if status else ""))
|
|
252
|
+
err.code = detail # type: ignore[attr-defined]
|
|
253
|
+
err.prompt_config_error = True # type: ignore[attr-defined]
|
|
254
|
+
raise err
|
|
225
255
|
raise LookupError(
|
|
226
256
|
f"trodo: could not fetch prompt {name!r}"
|
|
227
257
|
+ (f" (HTTP {status})" if status else "")
|
|
@@ -246,7 +276,12 @@ class PromptManager:
|
|
|
246
276
|
if ttl > 0:
|
|
247
277
|
self._cache.set(key, raw, ttl)
|
|
248
278
|
return _to_prompt(raw, trace_label=trace_label)
|
|
249
|
-
except Exception:
|
|
279
|
+
except Exception as e:
|
|
280
|
+
# A config error is not an outage: a typo'd label silently serving
|
|
281
|
+
# the fallback forever would hide the mistake for as long as it
|
|
282
|
+
# ships. Config errors surface.
|
|
283
|
+
if getattr(e, "prompt_config_error", False):
|
|
284
|
+
raise
|
|
250
285
|
stale = self._cache.get_stale(key)
|
|
251
286
|
if stale is not None:
|
|
252
287
|
return _to_prompt(stale, trace_label=trace_label)
|
|
@@ -258,6 +258,26 @@ def _mint_anon_distinct_id() -> str:
|
|
|
258
258
|
return f"anon_{ts}_python_{uuid.uuid4()}_{rand}"
|
|
259
259
|
|
|
260
260
|
|
|
261
|
+
def _tag_error_with_run(exc: object, run_id: Optional[str]) -> None:
|
|
262
|
+
"""Stamp the active run's id onto an exception about to propagate.
|
|
263
|
+
|
|
264
|
+
The run IS recorded server-side when an agent raises -- status, error type,
|
|
265
|
+
full message. What was missing is the join: the exception a developer's
|
|
266
|
+
error tracker captures had no reference to the recorded run, so the two
|
|
267
|
+
could only be matched by timestamp. ``exc.trodo_run_id`` is that join.
|
|
268
|
+
|
|
269
|
+
Guarded on purpose: exceptions can use ``__slots__`` or be otherwise
|
|
270
|
+
unwritable, and a crash inside error handling is the one unforgivable
|
|
271
|
+
failure mode here.
|
|
272
|
+
"""
|
|
273
|
+
if exc is None or not run_id:
|
|
274
|
+
return
|
|
275
|
+
try:
|
|
276
|
+
exc.trodo_run_id = run_id # type: ignore[attr-defined]
|
|
277
|
+
except Exception: # noqa: BLE001 -- slots/frozen; the run is still recorded
|
|
278
|
+
pass
|
|
279
|
+
|
|
280
|
+
|
|
261
281
|
class RunHandle:
|
|
262
282
|
"""Handle returned by wrap_agent for setting input/output and getting run_id."""
|
|
263
283
|
|
|
@@ -600,6 +620,7 @@ class wrap_agent:
|
|
|
600
620
|
einfo = describe_error(exc_type, exc, tb)
|
|
601
621
|
error_summary = einfo["error_message"]
|
|
602
622
|
error_type = einfo["error_type"]
|
|
623
|
+
_tag_error_with_run(exc, self.handle.run_id)
|
|
603
624
|
elif manual_run_error:
|
|
604
625
|
status = "error"
|
|
605
626
|
error_summary = self.handle.error_summary
|
|
@@ -704,6 +725,7 @@ class wrap_agent:
|
|
|
704
725
|
for k, v in self.handle.metadata.items():
|
|
705
726
|
otel_span.set_attribute(f"trodo.metadata.{k}", _serialize_attr(v))
|
|
706
727
|
if exc is not None:
|
|
728
|
+
_tag_error_with_run(exc, self.handle.run_id if self.handle else None)
|
|
707
729
|
otel_span.record_exception(exc)
|
|
708
730
|
_, status_cls, status_code = get_otel_helpers()
|
|
709
731
|
if status_cls is not None and status_code is not None:
|
|
@@ -8,6 +8,7 @@ or the prompt you tested is not the prompt you shipped.
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
+
import json
|
|
11
12
|
from typing import Any, Dict, List, Optional
|
|
12
13
|
|
|
13
14
|
from .template import render
|
|
@@ -26,6 +27,15 @@ class CompileError(Exception):
|
|
|
26
27
|
self.details: List[str] = details or []
|
|
27
28
|
|
|
28
29
|
|
|
30
|
+
def _q(value: Any) -> str:
|
|
31
|
+
"""Format an offending value the way Node's JSON.stringify does, so the two
|
|
32
|
+
engines produce byte-identical error text. repr() was the one divergence a
|
|
33
|
+
full cross-SDK parity run found."""
|
|
34
|
+
try:
|
|
35
|
+
return json.dumps(value, ensure_ascii=False)
|
|
36
|
+
except (TypeError, ValueError):
|
|
37
|
+
return repr(value)
|
|
38
|
+
|
|
29
39
|
def _coerce(value: Any, type_: str, name: str, errors: List[str]) -> Any:
|
|
30
40
|
"""Coerce a caller value to the declared type.
|
|
31
41
|
|
|
@@ -36,15 +46,18 @@ def _coerce(value: Any, type_: str, name: str, errors: List[str]) -> Any:
|
|
|
36
46
|
return value
|
|
37
47
|
|
|
38
48
|
if type_ == "number":
|
|
49
|
+
# _q (json.dumps), not repr: the Node engine formats the offending
|
|
50
|
+
# value with JSON.stringify, and the two engines' error text is
|
|
51
|
+
# asserted byte-identical.
|
|
39
52
|
if isinstance(value, bool):
|
|
40
|
-
errors.append(f"variable '{name}': expected a number, got {value
|
|
53
|
+
errors.append(f"variable '{name}': expected a number, got {_q(value)}")
|
|
41
54
|
return value
|
|
42
55
|
if isinstance(value, (int, float)):
|
|
43
56
|
return value
|
|
44
57
|
try:
|
|
45
58
|
return float(value) if "." in str(value) else int(value)
|
|
46
59
|
except (TypeError, ValueError):
|
|
47
|
-
errors.append(f"variable '{name}': expected a number, got {value
|
|
60
|
+
errors.append(f"variable '{name}': expected a number, got {_q(value)}")
|
|
48
61
|
return value
|
|
49
62
|
|
|
50
63
|
if type_ == "boolean":
|
|
@@ -54,7 +67,7 @@ def _coerce(value: Any, type_: str, name: str, errors: List[str]) -> Any:
|
|
|
54
67
|
return True
|
|
55
68
|
if value == "false":
|
|
56
69
|
return False
|
|
57
|
-
errors.append(f"variable '{name}': expected a boolean, got {value
|
|
70
|
+
errors.append(f"variable '{name}': expected a boolean, got {_q(value)}")
|
|
58
71
|
return value
|
|
59
72
|
|
|
60
73
|
if type_ == "string":
|
|
@@ -74,12 +87,10 @@ def build_scope(
|
|
|
74
87
|
) -> Dict[str, Any]:
|
|
75
88
|
"""Build the render scope from declarations + caller values.
|
|
76
89
|
|
|
77
|
-
Resolution order: caller value -> declared default ->
|
|
78
|
-
raises -> optional-and-absent renders empty.
|
|
90
|
+
Resolution order: caller value -> declared default -> empty.
|
|
79
91
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
check, because the default is itself a value.
|
|
92
|
+
A missing value never raises. The one thing this does reject is a value for
|
|
93
|
+
a variable the prompt does not declare, which is almost always a typo.
|
|
83
94
|
"""
|
|
84
95
|
errors: List[str] = []
|
|
85
96
|
scope: Dict[str, Any] = {}
|
|
@@ -104,24 +115,13 @@ def build_scope(
|
|
|
104
115
|
has = name in values
|
|
105
116
|
value = values.get(name) if has else None
|
|
106
117
|
|
|
107
|
-
#
|
|
108
|
-
#
|
|
109
|
-
#
|
|
110
|
-
#
|
|
111
|
-
supplied = has and value is not None and value != ""
|
|
112
|
-
|
|
113
|
-
# Missing value -> the declared default, or empty.
|
|
118
|
+
# A missing value resolves, it never fails: the declared default, or
|
|
119
|
+
# empty. That IS the point of declaring a default -- there is no third
|
|
120
|
+
# case where the caller has to have supplied something, and adding one
|
|
121
|
+
# would turn an empty render into a crash for no gain.
|
|
114
122
|
if not has or value is None:
|
|
115
123
|
value = default if has_default else ([] if type_ == "messages" else "")
|
|
116
124
|
|
|
117
|
-
# A default always satisfies the requirement, so the two never both
|
|
118
|
-
# apply; the editor won't let you author both, but a hand-written
|
|
119
|
-
# declaration can.
|
|
120
|
-
required = v.get("required") if isinstance(v, dict) else getattr(v, "required", None)
|
|
121
|
-
if not supplied and required is True and not has_default:
|
|
122
|
-
errors.append(f"variable '{name}': required, but no value was provided")
|
|
123
|
-
continue
|
|
124
|
-
|
|
125
125
|
scope[name] = _coerce(value, type_, name, errors)
|
|
126
126
|
|
|
127
127
|
# Passing something the prompt doesn't declare is nearly always a rename
|
|
@@ -26,24 +26,18 @@ class PromptVariable:
|
|
|
26
26
|
"""A declared variable.
|
|
27
27
|
|
|
28
28
|
Every competitor leaves variables undeclared and untyped — discovered by
|
|
29
|
-
regex, with no defaults
|
|
30
|
-
``compile()``
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
Marking a variable required means the caller must pass a value that will
|
|
38
|
-
actually render: an omitted key, ``None`` and ``""`` all fail, since all
|
|
39
|
-
three render as nothing (``0`` and ``False`` pass). A variable with
|
|
40
|
-
a ``default`` can never fail the check — the default is a value — so the two
|
|
41
|
-
are mutually exclusive in practice.
|
|
29
|
+
regex, and with no defaults. Knowing the declared set is what lets
|
|
30
|
+
``compile()`` catch a value passed for a variable that doesn't exist,
|
|
31
|
+
instead of silently rendering the wrong prompt.
|
|
32
|
+
|
|
33
|
+
A missing value never fails. It resolves to the declared ``default``, or to
|
|
34
|
+
empty — that is what declaring a default is FOR. There is deliberately no
|
|
35
|
+
``required`` flag: it would give the author a decision to get wrong and the
|
|
36
|
+
caller a crash where an empty render would do.
|
|
42
37
|
"""
|
|
43
38
|
|
|
44
39
|
name: str
|
|
45
40
|
type: str = "string"
|
|
46
|
-
required: bool = False
|
|
47
41
|
default: Any = None
|
|
48
42
|
description: Optional[str] = None
|
|
49
43
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|