dreadnode 2.0.31__py3-none-any.whl → 2.0.32__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 +12 -0
- dreadnode/agents/subagent.py +48 -15
- dreadnode/airt/message_parts.py +263 -0
- dreadnode/airt/multimodal.py +191 -31
- dreadnode/app/api/client.py +237 -0
- dreadnode/app/api/models.py +44 -0
- dreadnode/app/cli/capability.py +20 -0
- dreadnode/app/cli/main.py +6 -5
- dreadnode/app/cli/shared.py +11 -0
- dreadnode/app/cli/task.py +21 -0
- dreadnode/app/client/managed_client.py +2 -0
- dreadnode/app/client/models.py +2 -0
- dreadnode/app/client/runtime_client.py +100 -2
- dreadnode/app/server/app.py +211 -17
- dreadnode/app/server/worker_manager.py +16 -2
- dreadnode/app/tui/app.py +21 -8
- dreadnode/app/tui/model_manager.py +77 -3
- dreadnode/app/tui/screens/capabilities.py +43 -0
- dreadnode/app/tui/screens/environments.py +31 -1
- dreadnode/app/tui/screens/models.py +5 -21
- dreadnode/app/tui/screens/readme.py +101 -0
- dreadnode/app/tui/update_check.py +54 -0
- dreadnode/builtin_capabilities/dreadnode/skills/creating-capabilities/references/sdk/agents.md +3 -4
- dreadnode/builtin_capabilities/dreadnode/skills/creating-capabilities/references/sdk/capabilities.md +23 -0
- dreadnode/builtin_capabilities/dreadnode/skills/dreadnode-cli/references/cli/capability.md +1 -0
- dreadnode/builtin_capabilities/dreadnode/skills/dreadnode-cli/references/cli/task.md +1 -0
- dreadnode/capabilities/worker_runner.py +32 -4
- dreadnode/core/types/video.py +29 -5
- dreadnode/items/__init__.py +15 -0
- dreadnode/items/config.py +150 -0
- dreadnode/items/models.py +80 -0
- dreadnode/packaging/manifest.py +7 -0
- dreadnode/packaging/oci.py +57 -0
- dreadnode/tools/__init__.py +12 -0
- dreadnode/tools/report_items.py +662 -0
- dreadnode/tracing/constants.py +6 -0
- dreadnode/tracing/span.py +22 -0
- {dreadnode-2.0.31.dist-info → dreadnode-2.0.32.dist-info}/METADATA +5 -3
- {dreadnode-2.0.31.dist-info → dreadnode-2.0.32.dist-info}/RECORD +42 -36
- {dreadnode-2.0.31.dist-info → dreadnode-2.0.32.dist-info}/WHEEL +1 -1
- {dreadnode-2.0.31.dist-info → dreadnode-2.0.32.dist-info}/entry_points.txt +0 -0
- {dreadnode-2.0.31.dist-info → dreadnode-2.0.32.dist-info}/licenses/LICENSE +0 -0
dreadnode/agents/agent.py
CHANGED
|
@@ -194,6 +194,11 @@ class Agent(Executor[AgentEvent, Trajectory]):
|
|
|
194
194
|
# Private state
|
|
195
195
|
_generator: Generator | None = PrivateAttr(None, init=False)
|
|
196
196
|
_current_input: str = PrivateAttr("", init=False)
|
|
197
|
+
_capability: tuple[str, str] | None = PrivateAttr(None, init=False)
|
|
198
|
+
"""The (name, version) capability this agent runs under, if any. Bound to
|
|
199
|
+
``current_capability`` for the duration of a run (and reset afterward) so
|
|
200
|
+
runtime tools attribute emitted items to the right producer without leaking
|
|
201
|
+
attribution into later agents that share the async context."""
|
|
197
202
|
_permission_bridge: PermissionBridge | None = PrivateAttr(None, init=False)
|
|
198
203
|
"""Tool-approval bridge injected by the runtime so foreign engines wire their
|
|
199
204
|
permission callback into the native HITL path. ``None`` for bare-SDK use."""
|
|
@@ -1204,6 +1209,13 @@ class Agent(Executor[AgentEvent, Trajectory]):
|
|
|
1204
1209
|
active_trajectory = self.trajectory
|
|
1205
1210
|
|
|
1206
1211
|
async with AsyncExitStack() as stack:
|
|
1212
|
+
# Scope capability attribution to THIS run (reset on exit) so it never
|
|
1213
|
+
# leaks into a later agent sharing the async context.
|
|
1214
|
+
if self._capability is not None:
|
|
1215
|
+
from dreadnode.tracing.span import bind_capability
|
|
1216
|
+
|
|
1217
|
+
stack.enter_context(bind_capability(self._capability))
|
|
1218
|
+
|
|
1207
1219
|
# Enter tool contexts
|
|
1208
1220
|
for tool_container in self.tools:
|
|
1209
1221
|
if hasattr(tool_container, "__aenter__") and hasattr(tool_container, "__aexit__"):
|
dreadnode/agents/subagent.py
CHANGED
|
@@ -5,6 +5,7 @@ Similar to Claude Code's Task tool, this allows spawning specialized agents
|
|
|
5
5
|
to handle specific subtasks autonomously.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
+
import asyncio
|
|
8
9
|
import typing as t
|
|
9
10
|
from textwrap import dedent
|
|
10
11
|
|
|
@@ -93,8 +94,11 @@ class SubAgentToolset(Toolset):
|
|
|
93
94
|
parent_agent: t.Any = Config(default=None)
|
|
94
95
|
"""The parent agent to clone sub-agents from."""
|
|
95
96
|
|
|
96
|
-
|
|
97
|
-
"""
|
|
97
|
+
timeout: float | None = Config(default=3600.0)
|
|
98
|
+
"""Maximum seconds a spawned sub-agent may run before it is cancelled and
|
|
99
|
+
reported as a timeout. ``None`` disables the ceiling (unbounded). A wedged
|
|
100
|
+
tool inside a sub-agent (hung MCP transport, stuck LLM stream) would
|
|
101
|
+
otherwise hang the parent session indefinitely."""
|
|
98
102
|
|
|
99
103
|
@tool_method
|
|
100
104
|
async def spawn_agent(
|
|
@@ -126,7 +130,6 @@ class SubAgentToolset(Toolset):
|
|
|
126
130
|
- Complex tasks requiring focused work
|
|
127
131
|
- Exploration that might take many steps
|
|
128
132
|
- Tasks where you want isolated context
|
|
129
|
-
- Parallel work (with run_in_background)
|
|
130
133
|
|
|
131
134
|
## Examples
|
|
132
135
|
|
|
@@ -173,7 +176,37 @@ class SubAgentToolset(Toolset):
|
|
|
173
176
|
sub_agent.reset()
|
|
174
177
|
|
|
175
178
|
try:
|
|
176
|
-
|
|
179
|
+
# Bound the sub-agent run so a wedged tool inside it can't hang the
|
|
180
|
+
# parent session forever. ``asyncio.timeout(None)`` is a no-op, so a
|
|
181
|
+
# single path covers both the bounded and unbounded cases. On expiry
|
|
182
|
+
# it cancels the inner run and raises ``TimeoutError`` here; the
|
|
183
|
+
# ``CancelledError`` it uses internally never escapes. We check
|
|
184
|
+
# ``cm.expired()`` to tell our ceiling breach apart from a builtin
|
|
185
|
+
# ``TimeoutError`` raised *inside* the sub-agent, and deliberately do
|
|
186
|
+
# NOT catch ``CancelledError`` (a genuine parent-step cancellation
|
|
187
|
+
# must propagate, not be swallowed into a tool result).
|
|
188
|
+
async with asyncio.timeout(self.timeout) as cm:
|
|
189
|
+
trajectory = await sub_agent.run(task)
|
|
190
|
+
except TimeoutError as e:
|
|
191
|
+
if not cm.expired():
|
|
192
|
+
# A builtin ``TimeoutError`` from within the sub-agent (socket,
|
|
193
|
+
# inner ``async_timeout``, …) — not our ceiling. Surface it as an
|
|
194
|
+
# ordinary failure rather than a false "cancelled at ceiling".
|
|
195
|
+
logger.error(f"Sub-agent failed: {e}")
|
|
196
|
+
return f"Sub-agent failed: {e}"
|
|
197
|
+
logger.warning("Sub-agent '{}' timed out after {}s", config["name"], self.timeout)
|
|
198
|
+
# The cancelled run still burned tokens; carry its partial cost so
|
|
199
|
+
# the parent session's accounting isn't understated.
|
|
200
|
+
return self._tool_result(
|
|
201
|
+
f"Sub-agent '{config['name']}' timed out after {self.timeout} seconds "
|
|
202
|
+
"and was cancelled. Consider narrowing the task or raising the "
|
|
203
|
+
"SubAgentToolset timeout.",
|
|
204
|
+
sub_agent.trajectory,
|
|
205
|
+
)
|
|
206
|
+
except Exception as e:
|
|
207
|
+
logger.error(f"Sub-agent failed: {e}")
|
|
208
|
+
return f"Sub-agent failed: {e}"
|
|
209
|
+
else:
|
|
177
210
|
# Get the last assistant message content as the response
|
|
178
211
|
last_message = trajectory.messages[-1] if trajectory.messages else None
|
|
179
212
|
response = str(last_message.content) if last_message else ""
|
|
@@ -185,17 +218,17 @@ class SubAgentToolset(Toolset):
|
|
|
185
218
|
result += f"**Result:**\n{response}"
|
|
186
219
|
|
|
187
220
|
logger.info(f"Sub-agent completed in {len(trajectory.steps)} steps")
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
221
|
+
return self._tool_result(result, trajectory)
|
|
222
|
+
|
|
223
|
+
@staticmethod
|
|
224
|
+
def _tool_result(content: str, trajectory: t.Any) -> Message:
|
|
225
|
+
"""Build the tool-result message, stashing the sub-agent's LLM cost on
|
|
226
|
+
its metadata so the agent framework can lift it onto the ``ToolEnd``
|
|
227
|
+
event and the TUI can display it in the parent session's footer."""
|
|
228
|
+
msg = Message(role="tool", content=content)
|
|
229
|
+
if trajectory.usage.cost_usd is not None:
|
|
230
|
+
msg.metadata["subagent_cost_usd"] = trajectory.usage.cost_usd
|
|
231
|
+
return msg
|
|
199
232
|
|
|
200
233
|
|
|
201
234
|
def create_subagent_tool(parent_agent: "Agent") -> SubAgentToolset:
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Canonical multimodal message parts for AI Red Teaming.
|
|
3
|
+
|
|
4
|
+
A trial's input (candidate) and output (response) are represented as an ordered list of
|
|
5
|
+
``MessagePart`` dicts. Text parts carry their text inline; media parts (image/audio/video)
|
|
6
|
+
carry a *content-addressed reference* to bytes stored once on the trial span
|
|
7
|
+
(``span.log_artifact`` → ``sha256:`` oid in ``dreadnode.artifacts``), never inline base64.
|
|
8
|
+
|
|
9
|
+
The shape is a lossless superset of the OpenAI / Anthropic / Gemini content-part models
|
|
10
|
+
(discriminated ``source`` union: artifact | url | file_id) so it round-trips faithfully and
|
|
11
|
+
the platform can resolve refs to presigned inline URLs for display.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import io
|
|
15
|
+
import tempfile
|
|
16
|
+
import typing as t
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from loguru import logger
|
|
20
|
+
|
|
21
|
+
if t.TYPE_CHECKING:
|
|
22
|
+
from dreadnode.core.types import Audio, Image, Video
|
|
23
|
+
from dreadnode.tracing.span import TaskSpan
|
|
24
|
+
|
|
25
|
+
_MIME_BY_EXT = {
|
|
26
|
+
"png": "image/png",
|
|
27
|
+
"jpg": "image/jpeg",
|
|
28
|
+
"jpeg": "image/jpeg",
|
|
29
|
+
"gif": "image/gif",
|
|
30
|
+
"webp": "image/webp",
|
|
31
|
+
"bmp": "image/bmp",
|
|
32
|
+
"mp3": "audio/mpeg",
|
|
33
|
+
"wav": "audio/wav",
|
|
34
|
+
"ogg": "audio/ogg",
|
|
35
|
+
"flac": "audio/flac",
|
|
36
|
+
"m4a": "audio/mp4",
|
|
37
|
+
"mp4": "video/mp4",
|
|
38
|
+
"webm": "video/webm",
|
|
39
|
+
"mov": "video/quicktime",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _mime_for_extension(ext: str | None, default: str) -> str:
|
|
44
|
+
if not ext:
|
|
45
|
+
return default
|
|
46
|
+
return _MIME_BY_EXT.get(ext.lower().lstrip("."), default)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def text_part(
|
|
50
|
+
text: str,
|
|
51
|
+
*,
|
|
52
|
+
transform: str | None = None,
|
|
53
|
+
variant: str | None = None,
|
|
54
|
+
) -> dict[str, t.Any]:
|
|
55
|
+
"""Build a canonical text message part.
|
|
56
|
+
|
|
57
|
+
``transform``/``variant`` record provenance when a *text* transform was
|
|
58
|
+
applied, so the UI can attribute the transform to the text part (not only
|
|
59
|
+
to image/audio/video parts).
|
|
60
|
+
"""
|
|
61
|
+
part: dict[str, t.Any] = {"kind": "text", "text": text}
|
|
62
|
+
if transform:
|
|
63
|
+
part["transform"] = transform
|
|
64
|
+
if variant:
|
|
65
|
+
part["variant"] = variant
|
|
66
|
+
return part
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _store_bytes_as_artifact(
|
|
70
|
+
span: "TaskSpan",
|
|
71
|
+
data: bytes,
|
|
72
|
+
*,
|
|
73
|
+
filename: str,
|
|
74
|
+
) -> dict[str, t.Any] | None:
|
|
75
|
+
"""Persist bytes as a content-addressed span artifact, return the artifact metadata.
|
|
76
|
+
|
|
77
|
+
The artifact's MIME type is inferred from ``filename`` by ``log_artifact``; the
|
|
78
|
+
caller supplies the canonical ``media_type`` on the resulting part separately.
|
|
79
|
+
"""
|
|
80
|
+
suffix = Path(filename).suffix or ""
|
|
81
|
+
try:
|
|
82
|
+
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
|
|
83
|
+
tmp.write(data)
|
|
84
|
+
tmp_path = Path(tmp.name)
|
|
85
|
+
try:
|
|
86
|
+
return span.log_artifact(tmp_path, name=filename)
|
|
87
|
+
finally:
|
|
88
|
+
tmp_path.unlink(missing_ok=True)
|
|
89
|
+
except Exception:
|
|
90
|
+
# Storage may be unconfigured (e.g. local unit tests) — degrade to no media ref
|
|
91
|
+
# rather than failing the trial. The text parts still carry the attack content.
|
|
92
|
+
logger.debug("Failed to store media artifact for message part", exc_info=True)
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _media_part(
|
|
97
|
+
span: "TaskSpan",
|
|
98
|
+
data: bytes,
|
|
99
|
+
*,
|
|
100
|
+
kind: str,
|
|
101
|
+
media_type: str,
|
|
102
|
+
filename: str,
|
|
103
|
+
transform: str | None,
|
|
104
|
+
variant: str | None,
|
|
105
|
+
caption: str | None,
|
|
106
|
+
) -> dict[str, t.Any]:
|
|
107
|
+
"""Build a media message part, storing bytes as a content-addressed artifact."""
|
|
108
|
+
meta = _store_bytes_as_artifact(span, data, filename=filename)
|
|
109
|
+
source: dict[str, t.Any] = {
|
|
110
|
+
"ref": "artifact",
|
|
111
|
+
"media_type": (meta.get("mime_type") if meta else None) or media_type,
|
|
112
|
+
"byte_size": (meta.get("size") if meta else None) or len(data),
|
|
113
|
+
"filename": (meta.get("name") if meta else None) or filename,
|
|
114
|
+
}
|
|
115
|
+
if meta and meta.get("oid"):
|
|
116
|
+
source["artifact_oid"] = meta["oid"]
|
|
117
|
+
part: dict[str, t.Any] = {"kind": kind, "source": source}
|
|
118
|
+
if transform:
|
|
119
|
+
part["transform"] = transform
|
|
120
|
+
if variant:
|
|
121
|
+
part["variant"] = variant
|
|
122
|
+
if caption:
|
|
123
|
+
part["caption"] = caption
|
|
124
|
+
return part
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def image_part(
|
|
128
|
+
span: "TaskSpan",
|
|
129
|
+
image: "Image",
|
|
130
|
+
*,
|
|
131
|
+
transform: str | None = None,
|
|
132
|
+
variant: str | None = None,
|
|
133
|
+
filename: str = "image.png",
|
|
134
|
+
) -> dict[str, t.Any]:
|
|
135
|
+
"""Build an image message part from an ``Image``, storing its bytes as an artifact."""
|
|
136
|
+
try:
|
|
137
|
+
data, metadata = image.to_serializable()
|
|
138
|
+
media_type = _mime_for_extension(metadata.get("extension"), "image/png")
|
|
139
|
+
caption = metadata.get("caption")
|
|
140
|
+
except Exception:
|
|
141
|
+
buffer = io.BytesIO()
|
|
142
|
+
image.to_pil().save(buffer, format="PNG")
|
|
143
|
+
data, media_type, caption = buffer.getvalue(), "image/png", None
|
|
144
|
+
return _media_part(
|
|
145
|
+
span,
|
|
146
|
+
data,
|
|
147
|
+
kind="image",
|
|
148
|
+
media_type=media_type,
|
|
149
|
+
filename=filename,
|
|
150
|
+
transform=transform,
|
|
151
|
+
variant=variant,
|
|
152
|
+
caption=caption,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def audio_part(
|
|
157
|
+
span: "TaskSpan",
|
|
158
|
+
audio: "Audio",
|
|
159
|
+
*,
|
|
160
|
+
transform: str | None = None,
|
|
161
|
+
variant: str | None = None,
|
|
162
|
+
filename: str = "audio",
|
|
163
|
+
) -> dict[str, t.Any]:
|
|
164
|
+
"""Build an audio message part from an ``Audio``, storing its bytes as an artifact."""
|
|
165
|
+
data, metadata = audio.to_serializable()
|
|
166
|
+
ext = metadata.get("extension", "mp3")
|
|
167
|
+
media_type = _mime_for_extension(ext, "audio/mpeg")
|
|
168
|
+
name = filename if Path(filename).suffix else f"{filename}.{ext}"
|
|
169
|
+
return _media_part(
|
|
170
|
+
span,
|
|
171
|
+
data,
|
|
172
|
+
kind="audio",
|
|
173
|
+
media_type=media_type,
|
|
174
|
+
filename=name,
|
|
175
|
+
transform=transform,
|
|
176
|
+
variant=variant,
|
|
177
|
+
caption=metadata.get("transcript"),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def video_part(
|
|
182
|
+
span: "TaskSpan",
|
|
183
|
+
video: "Video",
|
|
184
|
+
*,
|
|
185
|
+
transform: str | None = None,
|
|
186
|
+
variant: str | None = None,
|
|
187
|
+
filename: str = "video",
|
|
188
|
+
) -> dict[str, t.Any]:
|
|
189
|
+
"""Build a video message part from a ``Video``, storing its bytes as an artifact."""
|
|
190
|
+
data, metadata = video.to_serializable()
|
|
191
|
+
ext = metadata.get("extension", "mp4")
|
|
192
|
+
media_type = _mime_for_extension(ext, "video/mp4")
|
|
193
|
+
name = filename if Path(filename).suffix else f"{filename}.{ext}"
|
|
194
|
+
return _media_part(
|
|
195
|
+
span,
|
|
196
|
+
data,
|
|
197
|
+
kind="video",
|
|
198
|
+
media_type=media_type,
|
|
199
|
+
filename=name,
|
|
200
|
+
transform=transform,
|
|
201
|
+
variant=variant,
|
|
202
|
+
caption=None,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def modality_from_parts(parts: list[dict[str, t.Any]]) -> str:
|
|
207
|
+
"""Derive input_modality from the kinds present in a part list."""
|
|
208
|
+
kinds = {str(p.get("kind")) for p in parts if p.get("kind")}
|
|
209
|
+
media = kinds - {"text"}
|
|
210
|
+
if not media:
|
|
211
|
+
return "text"
|
|
212
|
+
if len(media) == 1:
|
|
213
|
+
return next(iter(media)) # "image" | "audio" | "video"
|
|
214
|
+
return "multimodal"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def response_to_parts(
|
|
218
|
+
span: "TaskSpan",
|
|
219
|
+
response: t.Any,
|
|
220
|
+
) -> list[dict[str, t.Any]]:
|
|
221
|
+
"""
|
|
222
|
+
Build response message parts from a target's output.
|
|
223
|
+
|
|
224
|
+
Handles text-out targets (str), media-out targets (Image/Audio/Video), and mixed
|
|
225
|
+
outputs (a Message, or a list/dict containing media). Media bytes are stored as
|
|
226
|
+
content-addressed artifacts; text is inlined.
|
|
227
|
+
"""
|
|
228
|
+
from dreadnode.core.types import Audio, Image, Video
|
|
229
|
+
|
|
230
|
+
parts: list[dict[str, t.Any]] = []
|
|
231
|
+
|
|
232
|
+
def _walk(value: t.Any) -> None:
|
|
233
|
+
if value is None:
|
|
234
|
+
return
|
|
235
|
+
if isinstance(value, str):
|
|
236
|
+
if value:
|
|
237
|
+
parts.append(text_part(value))
|
|
238
|
+
elif isinstance(value, Image):
|
|
239
|
+
parts.append(image_part(span, value, variant="output", filename="response.png"))
|
|
240
|
+
elif isinstance(value, Audio):
|
|
241
|
+
parts.append(audio_part(span, value, variant="output", filename="response"))
|
|
242
|
+
elif isinstance(value, Video):
|
|
243
|
+
parts.append(video_part(span, value, variant="output", filename="response"))
|
|
244
|
+
elif isinstance(value, dict):
|
|
245
|
+
# A Message-like dict or provider payload — walk content/parts if present.
|
|
246
|
+
for key in ("content", "content_parts", "parts", "text"):
|
|
247
|
+
if key in value:
|
|
248
|
+
_walk(value[key])
|
|
249
|
+
return
|
|
250
|
+
parts.append(text_part(str(value)))
|
|
251
|
+
elif isinstance(value, (list, tuple)):
|
|
252
|
+
for item in value:
|
|
253
|
+
_walk(item)
|
|
254
|
+
else:
|
|
255
|
+
# Message object or unknown — try content_parts, else stringify.
|
|
256
|
+
content = getattr(value, "content_parts", None) or getattr(value, "content", None)
|
|
257
|
+
if content is not None and not isinstance(content, str):
|
|
258
|
+
_walk(content)
|
|
259
|
+
else:
|
|
260
|
+
parts.append(text_part(str(value)))
|
|
261
|
+
|
|
262
|
+
_walk(response)
|
|
263
|
+
return parts or [text_part("")]
|