dreadnode 2.0.32__py3-none-any.whl → 2.0.34__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.
- dreadnode/agents/agent.py +7 -0
- dreadnode/agents/sanitize.py +150 -0
- dreadnode/agents/subagent.py +16 -0
- dreadnode/agents/tools.py +9 -2
- dreadnode/airt/__init__.py +10 -0
- dreadnode/airt/assessment.py +25 -0
- dreadnode/airt/message_parts.py +81 -1
- dreadnode/airt/multimodal.py +214 -18
- dreadnode/airt/targets/__init__.py +39 -0
- dreadnode/airt/targets/auth.py +74 -0
- dreadnode/airt/targets/factory.py +39 -0
- dreadnode/airt/targets/http.py +45 -0
- dreadnode/airt/targets/message.py +38 -0
- dreadnode/airt/targets/spec.py +57 -0
- dreadnode/airt/targets/streaming/__init__.py +9 -0
- dreadnode/airt/targets/streaming/nova_sonic.py +312 -0
- dreadnode/app/api/client.py +38 -3
- dreadnode/app/cli/evaluation.py +15 -0
- dreadnode/app/client/transports.py +1 -0
- dreadnode/app/model_catalog.py +34 -1
- dreadnode/app/server/app.py +23 -2
- dreadnode/app/server/websocket.py +7 -0
- dreadnode/app/tui/app.py +6 -2
- dreadnode/app/tui/capabilities_manager.py +1 -0
- dreadnode/app/tui/dreadnode.tcss +48 -18
- dreadnode/app/tui/model_variants.py +32 -0
- dreadnode/app/tui/sessions_manager.py +57 -10
- dreadnode/app/tui/theme.py +24 -8
- dreadnode/app/tui/widgets/agent_dialog.py +7 -2
- dreadnode/app/tui/widgets/conversation.py +116 -20
- dreadnode/app/tui/widgets/tool.py +85 -8
- dreadnode/builtin_capabilities/dreadnode/skills/creating-capabilities/references/INDEX.md +1 -0
- dreadnode/builtin_capabilities/dreadnode/skills/creating-capabilities/references/capabilities/agent-output.md +139 -0
- dreadnode/builtin_capabilities/dreadnode/skills/creating-capabilities/references/capabilities/manifest.md +1 -0
- dreadnode/builtin_capabilities/dreadnode/skills/dreadnode-cli/references/cli/evaluation.md +1 -0
- dreadnode/builtin_capabilities/dreadnode/skills/dreadnode-cli/references/getting-started/authentication.md +1 -1
- dreadnode/builtin_capabilities/dreadnode/skills/dreadnode-concepts/references/INDEX.md +0 -1
- dreadnode/builtin_capabilities/dreadnode/skills/dreadnode-concepts/references/getting-started/authentication.md +1 -1
- dreadnode/builtin_capabilities/dreadnode/skills/dreadnode-concepts/references/getting-started/overview.md +6 -2
- dreadnode/builtin_capabilities/dreadnode/skills/dreadnode-concepts/references/getting-started/quickstart.md +6 -2
- dreadnode/builtin_capabilities/dreadnode/skills/dreadnode-concepts/references/platform/chat-models.md +15 -15
- dreadnode/builtin_capabilities/dreadnode/skills/dreadnode-concepts/references/platform/credits.md +8 -12
- dreadnode/builtin_capabilities/dreadnode/skills/dreadnode-concepts/references/platform/organizations.md +1 -1
- dreadnode/builtin_capabilities/dreadnode/skills/dreadnode-concepts/references/platform/overview.md +3 -17
- dreadnode/builtin_capabilities/dreadnode/skills/dreadnode-concepts/references/platform/projects.md +2 -0
- dreadnode/builtin_capabilities/dreadnode/skills/dreadnode-concepts/references/platform/settings.md +2 -13
- dreadnode/builtin_capabilities/dreadnode/skills/dreadnode-concepts/references/sandboxes/runtime-limits.md +2 -2
- dreadnode/capabilities/loader.py +2 -0
- dreadnode/generators/generator/litellm_.py +97 -4
- dreadnode/generators/message.py +11 -10
- dreadnode/packaging/oci.py +24 -13
- dreadnode/policies/__init__.py +14 -8
- dreadnode/scorers/__init__.py +3 -1
- dreadnode/scorers/judge.py +195 -1
- dreadnode/tools/report_items.py +21 -9
- dreadnode/tracing/constants.py +6 -0
- dreadnode/transforms/audio.py +1108 -0
- dreadnode/transforms/image.py +1398 -0
- dreadnode/transforms/video.py +715 -0
- {dreadnode-2.0.32.dist-info → dreadnode-2.0.34.dist-info}/METADATA +9 -1
- {dreadnode-2.0.32.dist-info → dreadnode-2.0.34.dist-info}/RECORD +64 -55
- dreadnode/builtin_capabilities/dreadnode/skills/dreadnode-concepts/references/platform/users.md +0 -92
- {dreadnode-2.0.32.dist-info → dreadnode-2.0.34.dist-info}/WHEEL +0 -0
- {dreadnode-2.0.32.dist-info → dreadnode-2.0.34.dist-info}/entry_points.txt +0 -0
- {dreadnode-2.0.32.dist-info → dreadnode-2.0.34.dist-info}/licenses/LICENSE +0 -0
dreadnode/agents/agent.py
CHANGED
|
@@ -50,6 +50,7 @@ from dreadnode.agents.reactions import (
|
|
|
50
50
|
Retry,
|
|
51
51
|
RetryWithFeedback,
|
|
52
52
|
)
|
|
53
|
+
from dreadnode.agents.sanitize import sanitize_orphan_tool_messages
|
|
53
54
|
from dreadnode.agents.tools import Tool, ToolCall, ToolMode, Toolset, discover_tools_on_obj
|
|
54
55
|
from dreadnode.agents.trajectory import Trajectory
|
|
55
56
|
from dreadnode.core.exceptions import warn_at_user_stacklevel
|
|
@@ -329,6 +330,12 @@ class Agent(Executor[AgentEvent, Trajectory]):
|
|
|
329
330
|
messages, params, post_transform = await transform_callback(messages, params)
|
|
330
331
|
post_transforms.append(post_transform)
|
|
331
332
|
|
|
333
|
+
# ENG-7343 / ENG-6214: general tool-call repair at the provider boundary.
|
|
334
|
+
# Strip orphan tool_results and synthesize placeholders for tool_use
|
|
335
|
+
# blocks whose results were lost (cancel mid-tool, compaction split).
|
|
336
|
+
# Anthropic + OpenAI both 400 on the orphan shape.
|
|
337
|
+
messages = sanitize_orphan_tool_messages(messages)
|
|
338
|
+
|
|
332
339
|
try:
|
|
333
340
|
if self.cache is not None and self.generator.supports_prompt_caching():
|
|
334
341
|
messages = caching.apply_cache_mode_to_messages(self.cache, [messages])[0]
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Trajectory sanitization for tool-call/tool-result integrity (ENG-7343, ENG-6214).
|
|
2
|
+
|
|
3
|
+
Strict providers (Anthropic, OpenAI, and OpenAI-compatible gateways) reject
|
|
4
|
+
requests whose tool-call structure is malformed. Mid-tool cancellation,
|
|
5
|
+
compaction, session resume, and weak-model output can all produce shapes that
|
|
6
|
+
400 the entire request and leave a session unrecoverable. This module is the
|
|
7
|
+
single pre-provider chokepoint that repairs them.
|
|
8
|
+
|
|
9
|
+
Two failure families, both surfacing as request-fatal 400s, are handled here:
|
|
10
|
+
|
|
11
|
+
1. **Orphan pairs.** An assistant ``tool_call`` with no matching ``tool_result``
|
|
12
|
+
(or the inverse). Anthropic 400s with "unexpected tool_use_id ... must have a
|
|
13
|
+
corresponding tool_use block". Cause: cancel mid-tool, or compaction splitting
|
|
14
|
+
a pair across the summarization boundary.
|
|
15
|
+
|
|
16
|
+
2. **Malformed tool_calls that the adapter silently drops.** A ``tool_call`` with
|
|
17
|
+
a blank ``function.name`` is dropped by Responses-API adapters while its
|
|
18
|
+
``function_call_output`` survives, producing OpenAI's
|
|
19
|
+
"No tool call found for function call output with call_id ..." 400 — the same
|
|
20
|
+
symptom as (1) but a different root cause the orphan pass cannot see (the id
|
|
21
|
+
is present on both sides, so the pair *looks* complete). An empty
|
|
22
|
+
``tool_calls`` array is likewise rejected by some gateways (DeepSeek). These
|
|
23
|
+
are normalized *before* the orphan analysis. Reference: hermes-agent
|
|
24
|
+
``agent/agent_runtime_helpers.py`` pre-call sanitizer.
|
|
25
|
+
|
|
26
|
+
None of these passes mutate the input messages or the persisted trajectory —
|
|
27
|
+
repaired messages are shallow copies, so stored history and prompt caching stay
|
|
28
|
+
byte-stable.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from collections import Counter
|
|
32
|
+
|
|
33
|
+
from loguru import logger
|
|
34
|
+
|
|
35
|
+
from dreadnode.generators.message import Message
|
|
36
|
+
|
|
37
|
+
_INTERRUPTED_TOOL_RESULT = "[Tool execution was interrupted]"
|
|
38
|
+
# A blank tool-call name is invalid anyway; a non-empty sentinel keeps the call
|
|
39
|
+
# paired with its result through Responses-API adapters that would otherwise drop
|
|
40
|
+
# the nameless call and orphan its output.
|
|
41
|
+
_EMPTY_NAME_SENTINEL = "invalid_tool_call"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _repair_tool_calls(messages: list[Message]) -> tuple[list[Message], int, int]:
|
|
45
|
+
"""Normalize assistant ``tool_calls`` that strict providers reject.
|
|
46
|
+
|
|
47
|
+
- Empty ``tool_calls`` list → ``None`` (some gateways 400 on empty arrays).
|
|
48
|
+
- Blank ``function.name`` → ``_EMPTY_NAME_SENTINEL`` so the call is not
|
|
49
|
+
silently dropped by the adapter and orphaned from its output (ENG-7343).
|
|
50
|
+
|
|
51
|
+
Returns ``(messages, emptied, renamed)``. Never mutates the input.
|
|
52
|
+
"""
|
|
53
|
+
result: list[Message] = []
|
|
54
|
+
emptied = 0
|
|
55
|
+
renamed = 0
|
|
56
|
+
|
|
57
|
+
for m in messages:
|
|
58
|
+
if m.role != "assistant" or not m.tool_calls:
|
|
59
|
+
# Collapse an empty (but non-None) tool_calls array to None.
|
|
60
|
+
if m.role == "assistant" and m.tool_calls is not None and not m.tool_calls:
|
|
61
|
+
result.append(m.model_copy(update={"tool_calls": None}))
|
|
62
|
+
emptied += 1
|
|
63
|
+
else:
|
|
64
|
+
result.append(m)
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
new_calls = []
|
|
68
|
+
changed = False
|
|
69
|
+
for tc in m.tool_calls:
|
|
70
|
+
if (tc.function.name or "").strip():
|
|
71
|
+
new_calls.append(tc)
|
|
72
|
+
continue
|
|
73
|
+
new_calls.append(
|
|
74
|
+
tc.model_copy(
|
|
75
|
+
update={
|
|
76
|
+
"function": tc.function.model_copy(update={"name": _EMPTY_NAME_SENTINEL})
|
|
77
|
+
}
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
renamed += 1
|
|
81
|
+
changed = True
|
|
82
|
+
|
|
83
|
+
result.append(m.model_copy(update={"tool_calls": new_calls}) if changed else m)
|
|
84
|
+
|
|
85
|
+
return result, emptied, renamed
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def sanitize_orphan_tool_messages(messages: list[Message]) -> list[Message]:
|
|
89
|
+
"""Return ``messages`` with tool-call/result integrity restored.
|
|
90
|
+
|
|
91
|
+
Runs the malformed-tool_call repair, then resolves orphan pairs. Does not
|
|
92
|
+
mutate the input list. Idempotent: ``f(f(x)) == f(x)``.
|
|
93
|
+
"""
|
|
94
|
+
messages, emptied, renamed = _repair_tool_calls(messages)
|
|
95
|
+
|
|
96
|
+
tool_use_ids: set[str] = {
|
|
97
|
+
tc.id for m in messages if m.role == "assistant" and m.tool_calls for tc in m.tool_calls
|
|
98
|
+
}
|
|
99
|
+
# A *count* of available results per id, not a set: duplicate or blank ids
|
|
100
|
+
# (weak models behind OpenAI-compatible gateways emit both) would collapse
|
|
101
|
+
# in a set, so a second same-id call whose result is missing would never be
|
|
102
|
+
# synthesized and reach the provider unmatched. Each call consumes one
|
|
103
|
+
# available result; a call with none left gets a synthesized result.
|
|
104
|
+
available_results: Counter[str] = Counter(
|
|
105
|
+
m.tool_call_id for m in messages if m.role == "tool" and m.tool_call_id is not None
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
result: list[Message] = []
|
|
109
|
+
synthesized = 0
|
|
110
|
+
stripped = 0
|
|
111
|
+
|
|
112
|
+
for m in messages:
|
|
113
|
+
if m.role == "tool" and m.tool_call_id is not None and m.tool_call_id not in tool_use_ids:
|
|
114
|
+
stripped += 1
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
result.append(m)
|
|
118
|
+
|
|
119
|
+
if m.role == "assistant" and m.tool_calls:
|
|
120
|
+
for tc in m.tool_calls:
|
|
121
|
+
if available_results[tc.id] > 0:
|
|
122
|
+
# Reserve one real result for this call; the rest of the loop
|
|
123
|
+
# keeps it in place when it is reached.
|
|
124
|
+
available_results[tc.id] -= 1
|
|
125
|
+
continue
|
|
126
|
+
result.append(
|
|
127
|
+
Message(
|
|
128
|
+
role="tool",
|
|
129
|
+
tool_call_id=tc.id,
|
|
130
|
+
content=_INTERRUPTED_TOOL_RESULT,
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
synthesized += 1
|
|
134
|
+
|
|
135
|
+
if synthesized or stripped or emptied or renamed:
|
|
136
|
+
# INFO, not DEBUG: any repair here means a malformed/orphan tool shape
|
|
137
|
+
# reached the provider boundary — a signal the compaction boundary
|
|
138
|
+
# detector, a cancel path, or a weak model let something through. We
|
|
139
|
+
# want to see this firing in the wild, not silently paper over it.
|
|
140
|
+
# See ENG-7343 / ENG-6551 Phase 2.
|
|
141
|
+
logger.info(
|
|
142
|
+
"sanitize_orphan_tool_messages: synthesized={}, stripped={}, "
|
|
143
|
+
"emptied_tool_calls={}, renamed_blank_tool_calls={}",
|
|
144
|
+
synthesized,
|
|
145
|
+
stripped,
|
|
146
|
+
emptied,
|
|
147
|
+
renamed,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
return result
|
dreadnode/agents/subagent.py
CHANGED
|
@@ -11,6 +11,7 @@ from textwrap import dedent
|
|
|
11
11
|
|
|
12
12
|
from loguru import logger
|
|
13
13
|
|
|
14
|
+
from dreadnode.agents.events import AgentEnd
|
|
14
15
|
from dreadnode.agents.tools import Toolset, tool_method
|
|
15
16
|
from dreadnode.core.meta import Config
|
|
16
17
|
from dreadnode.generators.message import Message
|
|
@@ -207,6 +208,21 @@ class SubAgentToolset(Toolset):
|
|
|
207
208
|
logger.error(f"Sub-agent failed: {e}")
|
|
208
209
|
return f"Sub-agent failed: {e}"
|
|
209
210
|
else:
|
|
211
|
+
terminal_event = next(
|
|
212
|
+
(event for event in reversed(trajectory.events) if isinstance(event, AgentEnd)),
|
|
213
|
+
None,
|
|
214
|
+
)
|
|
215
|
+
if terminal_event is not None and terminal_event.error:
|
|
216
|
+
error_text = str(terminal_event.error)
|
|
217
|
+
logger.error(
|
|
218
|
+
"Sub-agent ended with error | type={} | message={}",
|
|
219
|
+
type(terminal_event.error).__name__
|
|
220
|
+
if isinstance(terminal_event.error, BaseException)
|
|
221
|
+
else "error",
|
|
222
|
+
error_text,
|
|
223
|
+
)
|
|
224
|
+
return f"Sub-agent failed: {error_text}"
|
|
225
|
+
|
|
210
226
|
# Get the last assistant message content as the response
|
|
211
227
|
last_message = trajectory.messages[-1] if trajectory.messages else None
|
|
212
228
|
response = str(last_message.content) if last_message else ""
|
dreadnode/agents/tools.py
CHANGED
|
@@ -121,8 +121,15 @@ class FunctionDefinition(BaseModel):
|
|
|
121
121
|
if not isinstance(value, dict):
|
|
122
122
|
return value
|
|
123
123
|
|
|
124
|
-
|
|
125
|
-
|
|
124
|
+
# A function definition must always carry a parameters object with a
|
|
125
|
+
# `properties` key so it survives model_dump(exclude_none=True) and satisfies
|
|
126
|
+
# strict providers (e.g. Together, DeepInfra) that reject tool definitions
|
|
127
|
+
# missing the `parameters` field. Fill in an empty `properties` when absent
|
|
128
|
+
# while preserving any sibling schema keys (additionalProperties,
|
|
129
|
+
# patternProperties, required, $defs, ...) so free-form-map and strict
|
|
130
|
+
# no-arg schemas are not silently flattened.
|
|
131
|
+
if value.get("type") == "object" and not value.get("properties"):
|
|
132
|
+
return {**value, "properties": value.get("properties") or {}}
|
|
126
133
|
|
|
127
134
|
return value
|
|
128
135
|
|
dreadnode/airt/__init__.py
CHANGED
|
@@ -101,6 +101,12 @@ from dreadnode.airt.salami_slicing import salami_slicing_attack
|
|
|
101
101
|
from dreadnode.airt.self_persuasion import self_persuasion_attack
|
|
102
102
|
from dreadnode.airt.siren import siren_attack
|
|
103
103
|
from dreadnode.airt.tap import tap_attack
|
|
104
|
+
from dreadnode.airt.targets import (
|
|
105
|
+
TargetAuth,
|
|
106
|
+
TargetSpec,
|
|
107
|
+
build_target,
|
|
108
|
+
nova_sonic_target,
|
|
109
|
+
)
|
|
104
110
|
from dreadnode.airt.templatefuzz import templatefuzz_attack
|
|
105
111
|
from dreadnode.airt.tmap_trajectory import tmap_trajectory_attack
|
|
106
112
|
from dreadnode.airt.trojail import trojail_attack
|
|
@@ -108,6 +114,8 @@ from dreadnode.airt.watermark_removal import watermark_removal_attack
|
|
|
108
114
|
|
|
109
115
|
__all__ = [
|
|
110
116
|
"Assessment",
|
|
117
|
+
"TargetAuth",
|
|
118
|
+
"TargetSpec",
|
|
111
119
|
"adversarial_reasoning_attack",
|
|
112
120
|
"advpromptier_attack",
|
|
113
121
|
"alignment_faking_attack",
|
|
@@ -117,6 +125,7 @@ __all__ = [
|
|
|
117
125
|
"autodan_turbo_attack",
|
|
118
126
|
"autoredteamer_attack",
|
|
119
127
|
"beast_attack",
|
|
128
|
+
"build_target",
|
|
120
129
|
"cot_jailbreak_attack",
|
|
121
130
|
"crescendo_attack",
|
|
122
131
|
"deep_inception_attack",
|
|
@@ -136,6 +145,7 @@ __all__ = [
|
|
|
136
145
|
"multimodal_attack",
|
|
137
146
|
"nes_attack",
|
|
138
147
|
"nexus_attack",
|
|
148
|
+
"nova_sonic_target",
|
|
139
149
|
"pair_attack",
|
|
140
150
|
"persona_hijack_attack",
|
|
141
151
|
"prompt_attack",
|
dreadnode/airt/assessment.py
CHANGED
|
@@ -138,6 +138,22 @@ class Assessment:
|
|
|
138
138
|
|
|
139
139
|
self.target_config = target_config
|
|
140
140
|
self.attacker_config = attacker_config
|
|
141
|
+
|
|
142
|
+
# Infer first-class model ids from the configs when not passed explicitly,
|
|
143
|
+
# so a user who only provides target_config / attacker_config still gets
|
|
144
|
+
# model metadata on the assessment header AND findings (mirrors the
|
|
145
|
+
# platform-side backfill). Keys match the capability/TUI convention.
|
|
146
|
+
if self.target_model is None and self.target_config:
|
|
147
|
+
self.target_model = self.target_config.get("model")
|
|
148
|
+
if self.attacker_model is None and self.attacker_config:
|
|
149
|
+
self.attacker_model = self.attacker_config.get("model") or self.attacker_config.get(
|
|
150
|
+
"attacker_model"
|
|
151
|
+
)
|
|
152
|
+
if self.judge_model is None and self.attacker_config:
|
|
153
|
+
self.judge_model = self.attacker_config.get(
|
|
154
|
+
"evaluator_model"
|
|
155
|
+
) or self.attacker_config.get("judge")
|
|
156
|
+
|
|
141
157
|
self._attack_manifest = attack_manifest
|
|
142
158
|
self._workflow_run_id = workflow_run_id
|
|
143
159
|
self._workflow_script = workflow_script
|
|
@@ -350,6 +366,15 @@ class Assessment:
|
|
|
350
366
|
study.airt_category = self.goal_category
|
|
351
367
|
if self.goal_category and not study.airt_sub_category:
|
|
352
368
|
study.airt_sub_category = self.goal_category
|
|
369
|
+
# Propagate the assessment's model identifiers so findings carry the
|
|
370
|
+
# target/judge metadata without the caller passing airt_* kwargs to the
|
|
371
|
+
# attack factory (e.g. multimodal_attack, which the user builds directly).
|
|
372
|
+
if self.target_model and not study.airt_target_model:
|
|
373
|
+
study.airt_target_model = self.target_model
|
|
374
|
+
if self.judge_model and not study.airt_evaluator_model:
|
|
375
|
+
study.airt_evaluator_model = self.judge_model
|
|
376
|
+
if self.attacker_model and not study.airt_attacker_model:
|
|
377
|
+
study.airt_attacker_model = self.attacker_model
|
|
353
378
|
|
|
354
379
|
from dreadnode.airt.analytics.types import GoalCategory
|
|
355
380
|
|
dreadnode/airt/message_parts.py
CHANGED
|
@@ -46,6 +46,18 @@ def _mime_for_extension(ext: str | None, default: str) -> str:
|
|
|
46
46
|
return _MIME_BY_EXT.get(ext.lower().lstrip("."), default)
|
|
47
47
|
|
|
48
48
|
|
|
49
|
+
# Legacy/alias MIME types that browsers handle poorly, mapped to the standard.
|
|
50
|
+
_MIME_ALIASES = {
|
|
51
|
+
"audio/x-wav": "audio/wav",
|
|
52
|
+
"audio/wave": "audio/wav",
|
|
53
|
+
"audio/x-pn-wav": "audio/wav",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _normalize_mime(mime: str | None) -> str | None:
|
|
58
|
+
return _MIME_ALIASES.get(mime, mime) if mime else mime
|
|
59
|
+
|
|
60
|
+
|
|
49
61
|
def text_part(
|
|
50
62
|
text: str,
|
|
51
63
|
*,
|
|
@@ -106,9 +118,14 @@ def _media_part(
|
|
|
106
118
|
) -> dict[str, t.Any]:
|
|
107
119
|
"""Build a media message part, storing bytes as a content-addressed artifact."""
|
|
108
120
|
meta = _store_bytes_as_artifact(span, data, filename=filename)
|
|
121
|
+
# Prefer the caller's canonical media_type over log_artifact's guessed MIME:
|
|
122
|
+
# Python's mimetypes maps `.wav` to the legacy `audio/x-wav`, which some
|
|
123
|
+
# browsers refuse to play in an <audio> element. `_normalize_mime` maps such
|
|
124
|
+
# aliases back to the standard type.
|
|
125
|
+
resolved_mime = _normalize_mime(media_type or (meta.get("mime_type") if meta else None))
|
|
109
126
|
source: dict[str, t.Any] = {
|
|
110
127
|
"ref": "artifact",
|
|
111
|
-
"media_type":
|
|
128
|
+
"media_type": resolved_mime,
|
|
112
129
|
"byte_size": (meta.get("size") if meta else None) or len(data),
|
|
113
130
|
"filename": (meta.get("name") if meta else None) or filename,
|
|
114
131
|
}
|
|
@@ -226,12 +243,75 @@ def response_to_parts(
|
|
|
226
243
|
content-addressed artifacts; text is inlined.
|
|
227
244
|
"""
|
|
228
245
|
from dreadnode.core.types import Audio, Image, Video
|
|
246
|
+
from dreadnode.generators.message import (
|
|
247
|
+
ContentAudioInput,
|
|
248
|
+
ContentImageUrl,
|
|
249
|
+
ContentText,
|
|
250
|
+
ContentVideoUrl,
|
|
251
|
+
)
|
|
229
252
|
|
|
230
253
|
parts: list[dict[str, t.Any]] = []
|
|
231
254
|
|
|
232
255
|
def _walk(value: t.Any) -> None:
|
|
233
256
|
if value is None:
|
|
234
257
|
return
|
|
258
|
+
# SDK message content-part types (media-out responses: image/audio/video
|
|
259
|
+
# generated by the target land here). Extract the real bytes/text instead
|
|
260
|
+
# of stringifying the part's repr.
|
|
261
|
+
if isinstance(value, ContentText):
|
|
262
|
+
if value.text:
|
|
263
|
+
parts.append(text_part(value.text))
|
|
264
|
+
return
|
|
265
|
+
if isinstance(value, ContentImageUrl):
|
|
266
|
+
url = value.image_url.url
|
|
267
|
+
mime = "image/png"
|
|
268
|
+
if url.startswith("data:") and ";" in url:
|
|
269
|
+
mime = url[5:].split(";", 1)[0] or mime
|
|
270
|
+
parts.append(
|
|
271
|
+
_media_part(
|
|
272
|
+
span,
|
|
273
|
+
value.to_bytes(),
|
|
274
|
+
kind="image",
|
|
275
|
+
media_type=mime,
|
|
276
|
+
filename="response_image.png",
|
|
277
|
+
transform=None,
|
|
278
|
+
variant="output",
|
|
279
|
+
caption=None,
|
|
280
|
+
)
|
|
281
|
+
)
|
|
282
|
+
return
|
|
283
|
+
if isinstance(value, ContentAudioInput):
|
|
284
|
+
fmt = getattr(value.input_audio, "format", "wav") or "wav"
|
|
285
|
+
parts.append(
|
|
286
|
+
_media_part(
|
|
287
|
+
span,
|
|
288
|
+
value.to_bytes(),
|
|
289
|
+
kind="audio",
|
|
290
|
+
media_type=_mime_for_extension(fmt, "audio/wav"),
|
|
291
|
+
filename=f"response_audio.{fmt}",
|
|
292
|
+
transform=None,
|
|
293
|
+
variant="output",
|
|
294
|
+
caption=None,
|
|
295
|
+
)
|
|
296
|
+
)
|
|
297
|
+
return
|
|
298
|
+
if isinstance(value, ContentVideoUrl):
|
|
299
|
+
import base64 as _b64
|
|
300
|
+
|
|
301
|
+
data = _b64.b64decode(value.file.file_data.split(",", 1)[-1])
|
|
302
|
+
parts.append(
|
|
303
|
+
_media_part(
|
|
304
|
+
span,
|
|
305
|
+
data,
|
|
306
|
+
kind="video",
|
|
307
|
+
media_type=value.file.mimetype or "video/mp4",
|
|
308
|
+
filename=value.file.filename or "response_video.mp4",
|
|
309
|
+
transform=None,
|
|
310
|
+
variant="output",
|
|
311
|
+
caption=None,
|
|
312
|
+
)
|
|
313
|
+
)
|
|
314
|
+
return
|
|
235
315
|
if isinstance(value, str):
|
|
236
316
|
if value:
|
|
237
317
|
parts.append(text_part(value))
|