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.
Files changed (42) hide show
  1. dreadnode/agents/agent.py +12 -0
  2. dreadnode/agents/subagent.py +48 -15
  3. dreadnode/airt/message_parts.py +263 -0
  4. dreadnode/airt/multimodal.py +191 -31
  5. dreadnode/app/api/client.py +237 -0
  6. dreadnode/app/api/models.py +44 -0
  7. dreadnode/app/cli/capability.py +20 -0
  8. dreadnode/app/cli/main.py +6 -5
  9. dreadnode/app/cli/shared.py +11 -0
  10. dreadnode/app/cli/task.py +21 -0
  11. dreadnode/app/client/managed_client.py +2 -0
  12. dreadnode/app/client/models.py +2 -0
  13. dreadnode/app/client/runtime_client.py +100 -2
  14. dreadnode/app/server/app.py +211 -17
  15. dreadnode/app/server/worker_manager.py +16 -2
  16. dreadnode/app/tui/app.py +21 -8
  17. dreadnode/app/tui/model_manager.py +77 -3
  18. dreadnode/app/tui/screens/capabilities.py +43 -0
  19. dreadnode/app/tui/screens/environments.py +31 -1
  20. dreadnode/app/tui/screens/models.py +5 -21
  21. dreadnode/app/tui/screens/readme.py +101 -0
  22. dreadnode/app/tui/update_check.py +54 -0
  23. dreadnode/builtin_capabilities/dreadnode/skills/creating-capabilities/references/sdk/agents.md +3 -4
  24. dreadnode/builtin_capabilities/dreadnode/skills/creating-capabilities/references/sdk/capabilities.md +23 -0
  25. dreadnode/builtin_capabilities/dreadnode/skills/dreadnode-cli/references/cli/capability.md +1 -0
  26. dreadnode/builtin_capabilities/dreadnode/skills/dreadnode-cli/references/cli/task.md +1 -0
  27. dreadnode/capabilities/worker_runner.py +32 -4
  28. dreadnode/core/types/video.py +29 -5
  29. dreadnode/items/__init__.py +15 -0
  30. dreadnode/items/config.py +150 -0
  31. dreadnode/items/models.py +80 -0
  32. dreadnode/packaging/manifest.py +7 -0
  33. dreadnode/packaging/oci.py +57 -0
  34. dreadnode/tools/__init__.py +12 -0
  35. dreadnode/tools/report_items.py +662 -0
  36. dreadnode/tracing/constants.py +6 -0
  37. dreadnode/tracing/span.py +22 -0
  38. {dreadnode-2.0.31.dist-info → dreadnode-2.0.32.dist-info}/METADATA +5 -3
  39. {dreadnode-2.0.31.dist-info → dreadnode-2.0.32.dist-info}/RECORD +42 -36
  40. {dreadnode-2.0.31.dist-info → dreadnode-2.0.32.dist-info}/WHEEL +1 -1
  41. {dreadnode-2.0.31.dist-info → dreadnode-2.0.32.dist-info}/entry_points.txt +0 -0
  42. {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__"):
@@ -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
- run_in_background: bool = Config(default=False)
97
- """Whether to run sub-agents in background (not yet implemented)."""
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
- trajectory = await sub_agent.run(task)
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
- except Exception as e:
189
- logger.error(f"Sub-agent failed: {e}")
190
- return f"Sub-agent failed: {e}"
191
- else:
192
- # Stash the sub-agent's LLM cost on the tool result metadata so
193
- # the agent framework can lift it onto the ``ToolEnd`` event and
194
- # the TUI can display it in the parent session's footer.
195
- msg = Message(role="tool", content=result)
196
- if trajectory.usage.cost_usd is not None:
197
- msg.metadata["subagent_cost_usd"] = trajectory.usage.cost_usd
198
- return msg
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("")]