dreadnode 1.15.3__tar.gz → 1.17.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.
Files changed (227) hide show
  1. {dreadnode-1.15.3 → dreadnode-1.17.0}/.gitignore +1 -0
  2. {dreadnode-1.15.3 → dreadnode-1.17.0}/PKG-INFO +13 -5
  3. {dreadnode-1.15.3 → dreadnode-1.17.0}/README.md +1 -1
  4. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/agent/agent.py +147 -72
  5. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/agent/events.py +2 -3
  6. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/agent/result.py +4 -0
  7. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/agent/tools/base.py +68 -2
  8. dreadnode-1.17.0/dreadnode/agent/tools/fs.py +867 -0
  9. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/airt/attack/__init__.py +2 -0
  10. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/airt/attack/base.py +3 -0
  11. dreadnode-1.17.0/dreadnode/airt/attack/crescendo.py +221 -0
  12. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/airt/attack/goat.py +116 -14
  13. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/airt/attack/prompt.py +44 -22
  14. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/airt/attack/tap.py +7 -2
  15. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/airt/target/llm.py +37 -13
  16. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/api/client.py +163 -10
  17. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/api/models.py +79 -0
  18. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/cli/main.py +4 -0
  19. dreadnode-1.17.0/dreadnode/cli/rbac/__init__.py +3 -0
  20. dreadnode-1.17.0/dreadnode/cli/rbac/organizations.py +29 -0
  21. dreadnode-1.17.0/dreadnode/cli/rbac/workspaces.py +151 -0
  22. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/cli/shared.py +6 -0
  23. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/constants.py +29 -0
  24. dreadnode-1.17.0/dreadnode/data/assets/adversarial_benchmark_subset.csv +71 -0
  25. dreadnode-1.17.0/dreadnode/data/assets/ai_safety.csv +81 -0
  26. dreadnode-1.17.0/dreadnode/data/assets/bomb.jpg +0 -0
  27. dreadnode-1.17.0/dreadnode/data/assets/meth.png +0 -0
  28. dreadnode-1.17.0/dreadnode/data/templates/crescendo/variant_1.yaml +69 -0
  29. dreadnode-1.17.0/dreadnode/data/templates/crescendo/variant_2.yaml +43 -0
  30. dreadnode-1.17.0/dreadnode/data/templates/crescendo/variant_3.yaml +24 -0
  31. dreadnode-1.17.0/dreadnode/data/templates/crescendo/variant_4.yaml +49 -0
  32. dreadnode-1.17.0/dreadnode/data/templates/crescendo/variant_5.yaml +56 -0
  33. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/data_types/image.py +2 -2
  34. dreadnode-1.17.0/dreadnode/data_types/message.py +229 -0
  35. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/data_types/text.py +1 -1
  36. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/eval/console.py +1 -1
  37. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/eval/eval.py +137 -37
  38. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/eval/events.py +19 -0
  39. dreadnode-1.17.0/dreadnode/eval/hooks/__init__.py +13 -0
  40. dreadnode-1.17.0/dreadnode/eval/hooks/base.py +26 -0
  41. dreadnode-1.17.0/dreadnode/eval/hooks/transforms.py +104 -0
  42. dreadnode-1.17.0/dreadnode/eval/reactions.py +35 -0
  43. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/eval/sample.py +2 -1
  44. dreadnode-1.17.0/dreadnode/exporter.py +25 -0
  45. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/logging_.py +6 -1
  46. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/main.py +361 -25
  47. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/optimization/console.py +51 -10
  48. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/optimization/stop.py +18 -13
  49. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/optimization/study.py +96 -71
  50. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/optimization/trial.py +9 -0
  51. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/task.py +9 -8
  52. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/tracing/span.py +28 -12
  53. dreadnode-1.17.0/dreadnode/transforms/cipher.py +625 -0
  54. dreadnode-1.17.0/dreadnode/transforms/encoding.py +598 -0
  55. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/transforms/image.py +95 -0
  56. dreadnode-1.17.0/dreadnode/transforms/multimodal.py +155 -0
  57. dreadnode-1.17.0/dreadnode/transforms/perturbation.py +1492 -0
  58. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/transforms/refine.py +59 -18
  59. dreadnode-1.17.0/dreadnode/transforms/text.py +599 -0
  60. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/util.py +17 -0
  61. dreadnode-1.17.0/examples/airt/ai_red_teaming_eval.ipynb +1898 -0
  62. dreadnode-1.17.0/examples/airt/crescendo_attack.ipynb +162 -0
  63. dreadnode-1.17.0/examples/airt/graph_of_attacks_with_pruning.ipynb +181 -0
  64. dreadnode-1.17.0/examples/airt/multimodal_attack_eval.ipynb +263 -0
  65. dreadnode-1.17.0/examples/airt/tap_vs_goat_eval.ipynb +549 -0
  66. dreadnode-1.17.0/examples/airt/tree_of_attacks_with_pruning.ipynb +183 -0
  67. dreadnode-1.17.0/examples/airt/tree_of_attacks_with_pruning_transforms.ipynb +186 -0
  68. {dreadnode-1.15.3 → dreadnode-1.17.0}/pyproject.toml +18 -11
  69. dreadnode-1.17.0/tests/test_agent.py +629 -0
  70. dreadnode-1.17.0/tests/test_agent_lifecycle.py +239 -0
  71. dreadnode-1.17.0/tests/test_task_output_linking.py +141 -0
  72. dreadnode-1.15.3/dreadnode/agent/tools/fs.py +0 -395
  73. dreadnode-1.15.3/dreadnode/transforms/cipher.py +0 -68
  74. dreadnode-1.15.3/dreadnode/transforms/encoding.py +0 -79
  75. dreadnode-1.15.3/dreadnode/transforms/perturbation.py +0 -307
  76. dreadnode-1.15.3/dreadnode/transforms/text.py +0 -158
  77. dreadnode-1.15.3/examples/airt/graph_of_attacks_with_pruning.ipynb +0 -179
  78. dreadnode-1.15.3/examples/airt/tap_vs_goat_eval.ipynb +0 -2303
  79. dreadnode-1.15.3/examples/airt/tree_of_attacks_with_pruning.ipynb +0 -181
  80. {dreadnode-1.15.3 → dreadnode-1.17.0}/LICENSE +0 -0
  81. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/__init__.py +0 -0
  82. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/__main__.py +0 -0
  83. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/agent/__init__.py +0 -0
  84. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/agent/error.py +0 -0
  85. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/agent/format.py +0 -0
  86. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/agent/hooks/__init__.py +0 -0
  87. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/agent/hooks/backoff.py +0 -0
  88. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/agent/hooks/base.py +0 -0
  89. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/agent/hooks/metrics.py +0 -0
  90. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/agent/hooks/summarize.py +0 -0
  91. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/agent/prompts/__init__.py +0 -0
  92. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/agent/prompts/summarize.py +0 -0
  93. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/agent/reactions.py +0 -0
  94. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/agent/stop.py +0 -0
  95. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/agent/thread.py +0 -0
  96. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/agent/tools/__init__.py +0 -0
  97. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/agent/tools/execute.py +0 -0
  98. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/agent/tools/memory.py +0 -0
  99. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/agent/tools/planning.py +0 -0
  100. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/agent/tools/reporting.py +0 -0
  101. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/agent/tools/tasking.py +0 -0
  102. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/airt/__init__.py +0 -0
  103. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/airt/attack/hop_skip_jump.py +0 -0
  104. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/airt/attack/nes.py +0 -0
  105. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/airt/attack/simba.py +0 -0
  106. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/airt/attack/zoo.py +0 -0
  107. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/airt/search/__init__.py +0 -0
  108. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/airt/search/hop_skip_jump.py +0 -0
  109. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/airt/search/image_utils.py +0 -0
  110. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/airt/search/nes.py +0 -0
  111. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/airt/search/simba.py +0 -0
  112. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/airt/search/zoo.py +0 -0
  113. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/airt/target/__init__.py +0 -0
  114. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/airt/target/base.py +0 -0
  115. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/airt/target/custom.py +0 -0
  116. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/api/__init__.py +0 -0
  117. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/api/util.py +0 -0
  118. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/artifact/__init__.py +0 -0
  119. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/artifact/credential_manager.py +0 -0
  120. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/artifact/merger.py +0 -0
  121. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/artifact/storage.py +0 -0
  122. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/artifact/tree_builder.py +0 -0
  123. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/cli/__init__.py +0 -0
  124. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/cli/agent/__init__.py +0 -0
  125. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/cli/agent/cli.py +0 -0
  126. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/cli/api.py +0 -0
  127. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/cli/attack/__init__.py +0 -0
  128. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/cli/attack/cli.py +0 -0
  129. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/cli/docker.py +0 -0
  130. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/cli/eval/__init__.py +0 -0
  131. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/cli/eval/cli.py +0 -0
  132. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/cli/github.py +0 -0
  133. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/cli/platform/__init__.py +0 -0
  134. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/cli/platform/cli.py +0 -0
  135. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/cli/platform/compose.py +0 -0
  136. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/cli/platform/constants.py +0 -0
  137. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/cli/platform/download.py +0 -0
  138. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/cli/platform/env_mgmt.py +0 -0
  139. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/cli/platform/tag.py +0 -0
  140. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/cli/platform/version.py +0 -0
  141. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/cli/profile/__init__.py +0 -0
  142. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/cli/profile/cli.py +0 -0
  143. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/cli/study/__init__.py +0 -0
  144. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/cli/study/cli.py +0 -0
  145. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/cli/task/__init__.py +0 -0
  146. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/cli/task/cli.py +0 -0
  147. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/common_types.py +0 -0
  148. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/convert.py +0 -0
  149. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/data_types/__init__.py +0 -0
  150. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/data_types/audio.py +0 -0
  151. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/data_types/base.py +0 -0
  152. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/data_types/object_3d.py +0 -0
  153. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/data_types/table.py +0 -0
  154. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/data_types/video.py +0 -0
  155. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/discovery.py +0 -0
  156. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/error.py +0 -0
  157. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/eval/__init__.py +0 -0
  158. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/eval/dataset.py +0 -0
  159. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/eval/format.py +0 -0
  160. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/eval/result.py +0 -0
  161. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/format.py +0 -0
  162. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/integrations/__init__.py +0 -0
  163. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/integrations/transformers.py +0 -0
  164. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/meta/__init__.py +0 -0
  165. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/meta/config.py +0 -0
  166. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/meta/context.py +0 -0
  167. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/meta/hydrate.py +0 -0
  168. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/meta/introspect.py +0 -0
  169. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/metric.py +0 -0
  170. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/object.py +0 -0
  171. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/optimization/__init__.py +0 -0
  172. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/optimization/collectors.py +0 -0
  173. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/optimization/events.py +0 -0
  174. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/optimization/format.py +0 -0
  175. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/optimization/result.py +0 -0
  176. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/optimization/sampling.py +0 -0
  177. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/optimization/search/__init__.py +0 -0
  178. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/optimization/search/base.py +0 -0
  179. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/optimization/search/boundary.py +0 -0
  180. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/optimization/search/graph.py +0 -0
  181. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/optimization/search/optuna_.py +0 -0
  182. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/optimization/search/random.py +0 -0
  183. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/py.typed +0 -0
  184. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/scorers/__init__.py +0 -0
  185. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/scorers/base.py +0 -0
  186. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/scorers/classification.py +0 -0
  187. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/scorers/consistency.py +0 -0
  188. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/scorers/contains.py +0 -0
  189. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/scorers/crucible.py +0 -0
  190. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/scorers/format.py +0 -0
  191. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/scorers/harm.py +0 -0
  192. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/scorers/image.py +0 -0
  193. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/scorers/json.py +0 -0
  194. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/scorers/judge.py +0 -0
  195. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/scorers/length.py +0 -0
  196. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/scorers/lexical.py +0 -0
  197. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/scorers/pii.py +0 -0
  198. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/scorers/readability.py +0 -0
  199. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/scorers/rigging.py +0 -0
  200. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/scorers/sentiment.py +0 -0
  201. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/scorers/similarity.py +0 -0
  202. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/scorers/util.py +0 -0
  203. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/serialization.py +0 -0
  204. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/tracing/__init__.py +0 -0
  205. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/tracing/constants.py +0 -0
  206. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/tracing/exporters.py +0 -0
  207. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/transforms/__init__.py +0 -0
  208. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/transforms/base.py +0 -0
  209. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/transforms/stylistic.py +0 -0
  210. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/transforms/substitution.py +0 -0
  211. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/transforms/swap.py +0 -0
  212. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/user_config.py +0 -0
  213. {dreadnode-1.15.3 → dreadnode-1.17.0}/dreadnode/version.py +0 -0
  214. {dreadnode-1.15.3 → dreadnode-1.17.0}/examples/airt/beam_search.ipynb +0 -0
  215. {dreadnode-1.15.3 → dreadnode-1.17.0}/examples/data_export.ipynb +0 -0
  216. {dreadnode-1.15.3 → dreadnode-1.17.0}/examples/log_artifact.ipynb +0 -0
  217. {dreadnode-1.15.3 → dreadnode-1.17.0}/examples/log_object/audio.ipynb +0 -0
  218. {dreadnode-1.15.3 → dreadnode-1.17.0}/examples/log_object/image.ipynb +0 -0
  219. {dreadnode-1.15.3 → dreadnode-1.17.0}/examples/log_object/object3d.ipynb +0 -0
  220. {dreadnode-1.15.3 → dreadnode-1.17.0}/examples/log_object/table.ipynb +0 -0
  221. {dreadnode-1.15.3 → dreadnode-1.17.0}/examples/log_object/video.ipynb +0 -0
  222. {dreadnode-1.15.3 → dreadnode-1.17.0}/examples/model_training.ipynb +0 -0
  223. {dreadnode-1.15.3 → dreadnode-1.17.0}/examples/rigging.ipynb +0 -0
  224. {dreadnode-1.15.3 → dreadnode-1.17.0}/tests/cli/test_config.py +0 -0
  225. {dreadnode-1.15.3 → dreadnode-1.17.0}/tests/cli/test_docker.py +0 -0
  226. {dreadnode-1.15.3 → dreadnode-1.17.0}/tests/cli/test_github.py +0 -0
  227. {dreadnode-1.15.3 → dreadnode-1.17.0}/tests/test_meta.py +0 -0
@@ -156,6 +156,7 @@ venv.bak/
156
156
 
157
157
  # mkdocs documentation
158
158
  /site
159
+ debug.html
159
160
 
160
161
  # mypy
161
162
  .mypy_cache/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dreadnode
3
- Version: 1.15.3
3
+ Version: 1.17.0
4
4
  Summary: Dreadnode SDK
5
5
  Project-URL: Homepage, https://github.com/dreadnode/sdk
6
6
  Project-URL: Repository, https://github.com/dreadnode/sdk
@@ -208,21 +208,29 @@ License: Apache License
208
208
  See the License for the specific language governing permissions and
209
209
  limitations under the License.
210
210
  License-File: LICENSE
211
+ Classifier: License :: OSI Approved :: Apache Software License
212
+ Classifier: Operating System :: OS Independent
213
+ Classifier: Programming Language :: Python :: 3
214
+ Classifier: Programming Language :: Python :: 3.10
215
+ Classifier: Programming Language :: Python :: 3.11
216
+ Classifier: Programming Language :: Python :: 3.12
217
+ Classifier: Programming Language :: Python :: 3.13
211
218
  Requires-Python: <3.14,>=3.10
219
+ Requires-Dist: aiofiles<25.0.0,>=24.1.0
212
220
  Requires-Dist: coolname<3.0.0,>=2.2.0
213
221
  Requires-Dist: cyclopts>=4.2.0
214
- Requires-Dist: fsspec[s3]<=2025.3.0,>=2023.1.0
222
+ Requires-Dist: fsspec[s3]<=2025.12.0,>=2023.1.0
215
223
  Requires-Dist: httpx<1.0.0,>=0.28.0
216
224
  Requires-Dist: logfire<=3.20.0,>=3.5.3
217
225
  Requires-Dist: loguru>=0.7.3
218
- Requires-Dist: numpy<=2.2.6
226
+ Requires-Dist: numpy<=2.3.5
219
227
  Requires-Dist: optuna<5.0.0,>=4.5.0
220
228
  Requires-Dist: pandas<3.0.0,>=2.2.3
221
229
  Requires-Dist: pydantic<3.0.0,>=2.9.2
222
230
  Requires-Dist: python-jsonpath>=2.0.1
223
231
  Requires-Dist: python-ulid<4.0.0,>=3.0.0
224
232
  Requires-Dist: pyyaml>=6.0.2
225
- Requires-Dist: rigging<4.0.0,>=3.2.1
233
+ Requires-Dist: rigging>=3.3.4
226
234
  Requires-Dist: universal-pathlib<0.4.0,>=0.3.3
227
235
  Provides-Extra: all
228
236
  Requires-Dist: confusables<2.0.0,>=1.2.0; extra == 'all'
@@ -278,7 +286,7 @@ Dreadnode Strikes SDK
278
286
  <img alt="PyPI - Python Version" src="https://img.shields.io/pypi/pyversions/dreadnode">
279
287
  <img alt="PyPI - Version" src="https://img.shields.io/pypi/v/dreadnode">
280
288
  <img alt="GitHub License" src="https://img.shields.io/github/license/dreadnode/sdk">
281
- <img alt="Tests" src="https://img.shields.io/github/actions/workflow/status/dreadnode/sdk/tests.yaml">
289
+ <img alt="Tests" src="https://img.shields.io/github/actions/workflow/status/dreadnode/sdk/test.yaml">
282
290
  <img alt="Pre-Commit" src="https://img.shields.io/github/actions/workflow/status/dreadnode/sdk/pre-commit.yaml">
283
291
  <img alt="Renovate" src="https://img.shields.io/github/actions/workflow/status/dreadnode/sdk/renovate.yaml">
284
292
  </h4>
@@ -16,7 +16,7 @@ Dreadnode Strikes SDK
16
16
  <img alt="PyPI - Python Version" src="https://img.shields.io/pypi/pyversions/dreadnode">
17
17
  <img alt="PyPI - Version" src="https://img.shields.io/pypi/v/dreadnode">
18
18
  <img alt="GitHub License" src="https://img.shields.io/github/license/dreadnode/sdk">
19
- <img alt="Tests" src="https://img.shields.io/github/actions/workflow/status/dreadnode/sdk/tests.yaml">
19
+ <img alt="Tests" src="https://img.shields.io/github/actions/workflow/status/dreadnode/sdk/test.yaml">
20
20
  <img alt="Pre-Commit" src="https://img.shields.io/github/actions/workflow/status/dreadnode/sdk/pre-commit.yaml">
21
21
  <img alt="Renovate" src="https://img.shields.io/github/actions/workflow/status/dreadnode/sdk/renovate.yaml">
22
22
  </h4>
@@ -1,6 +1,8 @@
1
1
  import inspect
2
+ import json
3
+ import re
2
4
  import typing as t
3
- from contextlib import aclosing, asynccontextmanager
5
+ from contextlib import AsyncExitStack, aclosing, asynccontextmanager
4
6
  from copy import deepcopy
5
7
  from textwrap import dedent
6
8
 
@@ -20,7 +22,6 @@ from dreadnode.agent.events import (
20
22
  AgentEventInStep,
21
23
  AgentStalled,
22
24
  AgentStart,
23
- AgentStopReason,
24
25
  GenerationEnd,
25
26
  Reacted,
26
27
  StepStart,
@@ -37,7 +38,7 @@ from dreadnode.agent.reactions import (
37
38
  Retry,
38
39
  RetryWithFeedback,
39
40
  )
40
- from dreadnode.agent.result import AgentResult
41
+ from dreadnode.agent.result import AgentResult, AgentStopReason
41
42
  from dreadnode.agent.stop import StopCondition, never
42
43
  from dreadnode.agent.thread import Thread
43
44
  from dreadnode.agent.tools import AnyTool, Tool, Toolset, discover_tools_on_obj
@@ -59,7 +60,6 @@ from dreadnode.util import (
59
60
  litellm.suppress_debug_info = True
60
61
 
61
62
  CommitBehavior = t.Literal["always", "on-success"]
62
- HookMap = dict[type[AgentEvent], list[Hook]]
63
63
 
64
64
 
65
65
  class AgentWarning(UserWarning):
@@ -250,6 +250,9 @@ class Agent(Model):
250
250
  new.scorers = scorers if scorers is not None else new.scorers
251
251
  new.assert_scores = assert_scores if assert_scores is not None else new.assert_scores
252
252
 
253
+ # Retrigger model_post_init functions to ensure consistency
254
+ new.model_post_init(None)
255
+
253
256
  return new
254
257
 
255
258
  def _get_transforms(self) -> list[rg.Transform]:
@@ -269,29 +272,11 @@ class Agent(Model):
269
272
  transforms.append(rg.transform.tools_to_json_with_tag_transform)
270
273
  case "json":
271
274
  transforms.append(rg.transform.tools_to_json_transform)
275
+ case "pythonic":
276
+ transforms.append(rg.transform.tools_to_pythonic_transform)
272
277
 
273
278
  return transforms
274
279
 
275
- def _get_hooks(self) -> dict[type[AgentEvent], list[Hook]]:
276
- hooks: dict[type[AgentEvent], list[Hook]] = {}
277
- for hook in self.hooks:
278
- sig = inspect.signature(hook)
279
- if not (params := list(sig.parameters.values())):
280
- continue
281
- event_type = params[0].annotation
282
-
283
- if hasattr(event_type, "__origin__") and event_type.__origin__ is t.Union:
284
- union_args = event_type.__args__
285
- for arg in union_args:
286
- if inspect.isclass(arg) and issubclass(arg, AgentEvent):
287
- hooks.setdefault(arg, []).append(hook)
288
- elif inspect.isclass(event_type) and issubclass(event_type, AgentEvent):
289
- hooks.setdefault(event_type, []).append(hook)
290
- else:
291
- hooks.setdefault(AgentEvent, []).append(hook)
292
-
293
- return hooks
294
-
295
280
  async def _generate(
296
281
  self,
297
282
  messages: list[rg.Message],
@@ -352,7 +337,6 @@ class Agent(Model):
352
337
  self,
353
338
  thread: "Thread",
354
339
  messages: list[rg.Message],
355
- hooks: HookMap,
356
340
  *,
357
341
  commit: CommitBehavior,
358
342
  ) -> t.AsyncGenerator[AgentEvent, None]:
@@ -369,26 +353,21 @@ class Agent(Model):
369
353
 
370
354
  # Event dispatcher
371
355
 
372
- async def _dispatch(event: AgentEvent) -> t.AsyncIterator[AgentEvent]:
356
+ async def _dispatch(event: AgentEvent) -> t.AsyncIterator[AgentEvent]: # noqa: PLR0912
373
357
  nonlocal messages, events
374
358
 
375
359
  yield event
376
360
 
377
361
  events.append(event)
378
362
 
379
- # If we have no hooks, just return the event
380
- applicable_hooks = list(set(hooks.get(type(event), []) + hooks.get(AgentEvent, [])))
381
- if not applicable_hooks:
382
- return
383
-
384
363
  logger.debug(
385
364
  f"Agent '{self.name}' ({session_id}) dispatching '{type(event).__name__}': "
386
- f"applicable_hooks={[get_callable_name(h, short=True) for h in applicable_hooks]}"
365
+ f"hooks={[get_callable_name(h, short=True) for h in self.hooks]}"
387
366
  )
388
367
 
389
368
  # Run all applicable hooks and collect their reactions
390
369
  hook_reactions: dict[str, Reaction | None] = {}
391
- for hook in applicable_hooks:
370
+ for hook in self.hooks:
392
371
  hook_name = getattr(
393
372
  hook, "__name__", getattr(hook, "__qualname__", safe_repr(hook))
394
373
  )
@@ -415,6 +394,20 @@ class Agent(Model):
415
394
  )
416
395
  continue
417
396
 
397
+ if isinstance(event, AgentEnd):
398
+ warn_at_user_stacklevel(
399
+ f"Hook '{hook_name}' returned {reaction} during AgentEnd, but reactions are ignored at this stage.",
400
+ AgentWarning,
401
+ )
402
+ continue
403
+
404
+ if isinstance(event, Reacted):
405
+ warn_at_user_stacklevel(
406
+ f"Hook '{hook_name}' returned {reaction} during Reacted, but reactions are ignored at this stage.",
407
+ AgentWarning,
408
+ )
409
+ continue
410
+
418
411
  hook_reactions[hook_name] = reaction
419
412
 
420
413
  if not hook_reactions:
@@ -477,7 +470,9 @@ class Agent(Model):
477
470
  reaction=winning_reaction,
478
471
  )
479
472
  events.append(reacted_event)
480
- yield reacted_event
473
+
474
+ async for _event in _dispatch(reacted_event):
475
+ yield _event
481
476
 
482
477
  if isinstance(winning_reaction, Continue):
483
478
  messages = winning_reaction.messages
@@ -570,10 +565,12 @@ class Agent(Model):
570
565
 
571
566
  # Core step loop
572
567
 
573
- step = 1
568
+ step = 0
574
569
  error: Exception | str | None = None
575
570
 
576
- while step <= self.max_steps + 1:
571
+ while step < self.max_steps:
572
+ step += 1
573
+
577
574
  try:
578
575
  async for event in _dispatch(
579
576
  StepStart(
@@ -589,7 +586,7 @@ class Agent(Model):
589
586
 
590
587
  # Generation
591
588
 
592
- step_chat = await self._generate(messages=messages)
589
+ step_chat = await self._generate(messages)
593
590
  if step_chat.failed and step_chat.error:
594
591
  async for event in _dispatch(
595
592
  AgentError(
@@ -602,7 +599,9 @@ class Agent(Model):
602
599
  )
603
600
  ):
604
601
  yield event
605
- raise step_chat.error
602
+
603
+ error = t.cast("Exception", step_chat.error) # Should be Exception in rigging
604
+ break
606
605
 
607
606
  # Sync extra fields to metadata for storage
608
607
  step_chat.generated[-1].metadata.update(step_chat.extra)
@@ -649,7 +648,8 @@ class Agent(Model):
649
648
  ):
650
649
  yield event
651
650
 
652
- continue
651
+ # If the agent is stalled and nobody handled it, break out
652
+ break
653
653
 
654
654
  # Process tool calls
655
655
 
@@ -675,8 +675,6 @@ class Agent(Model):
675
675
  if any(cond(events) for cond in stop_conditions):
676
676
  break
677
677
 
678
- step += 1
679
-
680
678
  except Retry as e:
681
679
  messages = e.messages or messages
682
680
  continue
@@ -689,7 +687,7 @@ class Agent(Model):
689
687
  break
690
688
 
691
689
  stop_reason: AgentStopReason = "finished"
692
- if step > self.max_steps + 1:
690
+ if step >= self.max_steps:
693
691
  error = MaxStepsError(max_steps=self.max_steps)
694
692
  stop_reason = "max_steps_reached"
695
693
  elif error is not None:
@@ -720,28 +718,32 @@ class Agent(Model):
720
718
  else:
721
719
  logger.warning(log_message)
722
720
 
723
- yield AgentEnd(
724
- session_id=session_id,
725
- agent=self,
726
- thread=thread,
727
- messages=messages,
728
- events=events,
729
- stop_reason=stop_reason,
730
- result=AgentResult(
721
+ async for event in _dispatch(
722
+ AgentEnd(
723
+ session_id=session_id,
731
724
  agent=self,
725
+ thread=thread,
732
726
  messages=messages,
733
- usage=_total_usage_from_events(events),
734
- steps=step,
735
- failed=stop_reason != "finished",
736
- error=error,
737
- ),
738
- )
727
+ events=events,
728
+ stop_reason=stop_reason,
729
+ result=AgentResult(
730
+ agent=self,
731
+ messages=messages,
732
+ stop_reason=stop_reason,
733
+ usage=_total_usage_from_events(events),
734
+ steps=step,
735
+ failed=stop_reason != "finished",
736
+ error=error,
737
+ ),
738
+ )
739
+ ):
740
+ yield event
739
741
 
740
742
  def _log_event_metrics(self, event: AgentEvent) -> None:
741
743
  from dreadnode import log_metric
742
744
 
743
745
  if isinstance(event, AgentEnd):
744
- log_metric("steps_taken", min(0, event.result.steps - 1))
746
+ log_metric("steps_taken", max(0, event.result.steps - 1))
745
747
  log_metric(f"stop_{event.stop_reason}", 1)
746
748
 
747
749
  if not isinstance(event, AgentEventInStep):
@@ -778,7 +780,6 @@ class Agent(Model):
778
780
  ) -> t.AsyncGenerator[AgentEvent, None]:
779
781
  from dreadnode import log_output, log_outputs, score, task_and_run
780
782
 
781
- hooks = self._get_hooks()
782
783
  messages = [*deepcopy(thread.messages), rg.Message("user", str(user_input))]
783
784
 
784
785
  configuration = get_config_model(self)()
@@ -822,7 +823,7 @@ class Agent(Model):
822
823
  params=trace_params,
823
824
  ):
824
825
  try:
825
- async with aclosing(self._stream(thread, messages, hooks, commit=commit)) as stream:
826
+ async with aclosing(self._stream(thread, messages, commit=commit)) as stream:
826
827
  async for event in stream:
827
828
  last_event = event
828
829
  self._log_event_metrics(event)
@@ -837,7 +838,7 @@ class Agent(Model):
837
838
  if isinstance(last_event, AgentEnd):
838
839
  log_outputs(
839
840
  to="both",
840
- steps_taken=min(0, last_event.result.steps - 1),
841
+ steps_taken=max(0, last_event.result.steps - 1),
841
842
  reason=last_event.stop_reason,
842
843
  failed=last_event.result.failed,
843
844
  )
@@ -874,8 +875,16 @@ class Agent(Model):
874
875
  commit: CommitBehavior = "always",
875
876
  ) -> t.AsyncIterator[t.AsyncGenerator[AgentEvent, None]]:
876
877
  thread = thread or self.thread
877
- async with aclosing(self._stream_traced(thread, user_input, commit=commit)) as stream:
878
- yield stream
878
+
879
+ async with AsyncExitStack() as stack:
880
+ # Ensure all tools are properly entered if they
881
+ # are context managers before we start using them
882
+ for tool_container in self.tools:
883
+ if hasattr(tool_container, "__aenter__") and hasattr(tool_container, "__aexit__"):
884
+ await stack.enter_async_context(tool_container)
885
+
886
+ async with aclosing(self._stream_traced(thread, user_input, commit=commit)) as stream:
887
+ yield stream
879
888
 
880
889
  async def run(
881
890
  self,
@@ -897,7 +906,7 @@ class Agent(Model):
897
906
 
898
907
  class TaskAgent(Agent):
899
908
  """
900
- A specialized agent for running tasks with a focus on completion and reporting.
909
+ A specialized agent mixin for running tasks with a focus on completion and reporting.
901
910
  It extends the base Agent class to provide task-specific functionality.
902
911
 
903
912
  - Automatically includes the `finish_task`, `give_up_on_task`, and `update_todo` tools.
@@ -905,7 +914,12 @@ class TaskAgent(Agent):
905
914
  - Uses the `AgentStalled` event to handle stalled tasks by pushing the model to continue or finish the task.
906
915
  """
907
916
 
908
- def model_post_init(self, _: t.Any) -> None:
917
+ def model_post_init(self, context: t.Any) -> None:
918
+ super().model_post_init(context)
919
+
920
+ # TODO(nick): Would be better to have a pattern here for
921
+ # add-if-missing for tools, hooks, and stop conditions
922
+
909
923
  if not any(tool for tool in self.tools if tool.name == "finish_task"):
910
924
  self.tools.append(finish_task)
911
925
 
@@ -916,11 +930,72 @@ class TaskAgent(Agent):
916
930
  self.tools.append(update_todo)
917
931
 
918
932
  # Force the agent to use finish_task
919
- self.stop_conditions.append(never())
920
- self.hooks.insert(
921
- 0,
922
- retry_with_feedback(
923
- event_type=AgentStalled,
924
- feedback="Continue the task if possible, use the 'finish_task' tool to complete it, or 'give_up_on_task' if it cannot be completed.",
925
- ),
926
- )
933
+ if not any(cond for cond in self.stop_conditions if cond.name == "stop_never"):
934
+ self.stop_conditions.append(never())
935
+
936
+ if not any(
937
+ hook
938
+ for hook in self.hooks
939
+ if get_callable_name(hook, short=True) == "retry_with_feedback"
940
+ ):
941
+ self.hooks.append(
942
+ retry_with_feedback(
943
+ event_type=AgentStalled,
944
+ feedback="No tool calls were observed. Continue the task if possible, use the 'finish_task' tool to complete it, or 'give_up_on_task' if it cannot be completed.",
945
+ )
946
+ )
947
+
948
+
949
+ class RegexRefAgent(Agent):
950
+ """
951
+ An agent mixin that allows for dynamic references of prior text using regex patterns in tool arguments.
952
+ This helps prevent repeating large amounts of prior text in tool calls.
953
+
954
+ Instructions are automatically added to the agent's instructions to guide usage of the {find:<pattern>} syntax
955
+ along with a hook that resolves these references during tool calls.
956
+ """
957
+
958
+ @staticmethod
959
+ async def resolve_regex_ref(event: AgentEvent) -> Reaction | None:
960
+ if not isinstance(event, ToolStart):
961
+ return None
962
+
963
+ for m in re.finditer(r"\{find:(.*?)\}", event.tool_call.arguments):
964
+ regex = m.group(1).replace("\\\\", "\\") # models tend to over-escape
965
+ logger.info(f"Found find reference: {regex}")
966
+ all_message_content = "\n\n".join([m.content for m in event.messages])
967
+ reference_matches = re.findall(regex, all_message_content)
968
+ if reference_matches:
969
+ logger.debug(f"Replacing '{m.group(0)}' with '{reference_matches[-1][:50]}...'.")
970
+ event.tool_call.function.arguments = event.tool_call.arguments.replace(
971
+ m.group(0), json.dumps(reference_matches[-1]).strip('"')
972
+ )
973
+
974
+ return None
975
+
976
+ def model_post_init(self, context: t.Any) -> None:
977
+ super().model_post_init(context)
978
+
979
+ if not any(
980
+ hook
981
+ for hook in self.hooks
982
+ if get_callable_name(hook, short=True) == "resolve_regex_ref"
983
+ ):
984
+ self.hooks.append(RegexRefAgent.resolve_regex_ref)
985
+
986
+ instruction_section = dedent("""
987
+ # Regex Find Instructions
988
+ To efficiently reuse data from the conversation, you can pass {find:<pattern>} anywhere in tool arguments to dynamically
989
+ refer to prior text using a regex pattern. This helps prevent costly repetition of prior text.
990
+
991
+ You must escape special characters in the regex.
992
+
993
+ Example: If the history contains `$krb5tgs$23$*user...<long_hash>`, use:
994
+ `hashcat(hashes=["{find:\\$krb5tgs\\$.*}"], wordlist="...")`
995
+ and the system will find the full hash for you and insert it into the tool call.
996
+ """)
997
+
998
+ if self.instructions is None:
999
+ self.instructions = instruction_section
1000
+ elif self.instructions and instruction_section not in self.instructions:
1001
+ self.instructions += "\n\n" + instruction_section
@@ -24,13 +24,12 @@ from dreadnode.util import format_dict, shorten_string
24
24
  if t.TYPE_CHECKING:
25
25
  from dreadnode.agent.agent import Agent
26
26
  from dreadnode.agent.reactions import Reaction
27
- from dreadnode.agent.result import AgentResult
27
+ from dreadnode.agent.result import AgentResult, AgentStopReason
28
28
  from dreadnode.agent.thread import Thread
29
29
  from dreadnode.common_types import AnyDict
30
30
 
31
31
 
32
32
  AgentEventT = t.TypeVar("AgentEventT", bound="AgentEvent")
33
- AgentStopReason = t.Literal["finished", "max_steps_reached", "error", "stalled"]
34
33
 
35
34
 
36
35
  @dataclass
@@ -292,7 +291,7 @@ class Reacted(AgentEventInStep):
292
291
 
293
292
  @dataclass
294
293
  class AgentEnd(AgentEvent):
295
- stop_reason: AgentStopReason
294
+ stop_reason: "AgentStopReason"
296
295
  result: "AgentResult"
297
296
 
298
297
  def format_as_panel(self, *, truncate: bool = False) -> Panel: # noqa: ARG002
@@ -8,10 +8,13 @@ from rigging.message import Message
8
8
  if t.TYPE_CHECKING:
9
9
  from dreadnode.agent.agent import Agent
10
10
 
11
+ AgentStopReason = t.Literal["finished", "max_steps_reached", "error", "stalled"]
12
+
11
13
 
12
14
  @dataclass(config=ConfigDict(arbitrary_types_allowed=True))
13
15
  class AgentResult:
14
16
  agent: "Agent"
17
+ stop_reason: AgentStopReason
15
18
  messages: list[Message]
16
19
  usage: Usage
17
20
  steps: int
@@ -23,6 +26,7 @@ class AgentResult:
23
26
  f"agent={self.agent.name}",
24
27
  f"messages={len(self.messages)}",
25
28
  f"usage='{self.usage}'",
29
+ f"stop_reason='{self.stop_reason}'",
26
30
  f"steps={self.steps}",
27
31
  ]
28
32
 
@@ -1,6 +1,8 @@
1
+ import asyncio
2
+ import functools
1
3
  import typing as t
2
4
 
3
- from pydantic import ConfigDict
5
+ from pydantic import ConfigDict, PrivateAttr
4
6
  from rigging import tools
5
7
  from rigging.tools.base import ToolMethod as RiggingToolMethod
6
8
 
@@ -171,18 +173,82 @@ class Toolset(Model):
171
173
  - Pydantic's declarative syntax for defining state (fields).
172
174
  - Automatic application of the `@configurable` decorator.
173
175
  - A `get_tools` method for discovering methods decorated with `@dreadnode.tool_method`.
176
+ - Support for async context management, with automatic re-entrancy handling.
174
177
  """
175
178
 
179
+ model_config = ConfigDict(arbitrary_types_allowed=True, use_attribute_docstrings=True)
180
+
176
181
  variant: str | None = None
177
182
  """The variant for filtering tools available in this toolset."""
178
183
 
179
- model_config = ConfigDict(arbitrary_types_allowed=True, use_attribute_docstrings=True)
184
+ # Context manager magic
185
+ _entry_ref_count: int = PrivateAttr(default=0)
186
+ _context_handle: object = PrivateAttr(default=None)
187
+ _entry_lock: asyncio.Lock = PrivateAttr(default_factory=asyncio.Lock)
180
188
 
181
189
  @property
182
190
  def name(self) -> str:
183
191
  """The name of the toolset, derived from the class name."""
184
192
  return self.__class__.__name__
185
193
 
194
+ def __init_subclass__(cls, **kwargs: t.Any) -> None:
195
+ super().__init_subclass__(**kwargs)
196
+
197
+ # This essentially ensures that if the Toolset is any kind of context manager,
198
+ # it will be re-entrant, and only actually enter/exit once. This means we can
199
+ # safely build auto-entry/exit logic into our Agent class without worrying about
200
+ # breaking the code if the user happens to enter a toolset manually before using
201
+ # it in an agent.
202
+
203
+ original_aenter = cls.__dict__.get("__aenter__")
204
+ original_enter = cls.__dict__.get("__enter__")
205
+ original_aexit = cls.__dict__.get("__aexit__")
206
+ original_exit = cls.__dict__.get("__exit__")
207
+
208
+ has_enter = callable(original_aenter) or callable(original_enter)
209
+ has_exit = callable(original_aexit) or callable(original_exit)
210
+
211
+ if has_enter and not has_exit:
212
+ raise TypeError(
213
+ f"{cls.__name__} defining __aenter__ or __enter__ must also define __aexit__ or __exit__"
214
+ )
215
+ if has_exit and not has_enter:
216
+ raise TypeError(
217
+ f"{cls.__name__} defining __aexit__ or __exit__ must also define __aenter__ or __enter__"
218
+ )
219
+ if original_aenter and original_enter:
220
+ raise TypeError(f"{cls.__name__} cannot define both __aenter__ and __enter__")
221
+ if original_aexit and original_exit:
222
+ raise TypeError(f"{cls.__name__} cannot define both __aexit__ and __exit__")
223
+
224
+ @functools.wraps(original_aenter or original_enter) # type: ignore[arg-type]
225
+ async def aenter_wrapper(self: "Toolset", *args: t.Any, **kwargs: t.Any) -> t.Any:
226
+ async with self._entry_lock:
227
+ if self._entry_ref_count == 0:
228
+ handle = None
229
+ if original_aenter:
230
+ handle = await original_aenter(self, *args, **kwargs)
231
+ elif original_enter:
232
+ handle = original_enter(self, *args, **kwargs)
233
+ self._context_handle = handle if handle is not None else self
234
+ self._entry_ref_count += 1
235
+ return self._context_handle
236
+
237
+ cls.__aenter__ = aenter_wrapper # type: ignore[attr-defined]
238
+
239
+ @functools.wraps(original_aexit or original_exit) # type: ignore[arg-type]
240
+ async def aexit_wrapper(self: "Toolset", *args: t.Any, **kwargs: t.Any) -> t.Any:
241
+ async with self._entry_lock:
242
+ self._entry_ref_count -= 1
243
+ if self._entry_ref_count == 0:
244
+ if original_aexit:
245
+ await original_aexit(self, *args, **kwargs)
246
+ elif original_exit:
247
+ original_exit(self, *args, **kwargs)
248
+ self._context_handle = None
249
+
250
+ cls.__aexit__ = aexit_wrapper # type: ignore[attr-defined]
251
+
186
252
  def get_tools(self, *, variant: str | None = None) -> list[AnyTool]:
187
253
  variant = variant or self.variant
188
254