ivoryos-edge 0.1.1__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 (206) hide show
  1. ivoryos_edge-0.1.1/MANIFEST.in +2 -0
  2. ivoryos_edge-0.1.1/PKG-INFO +70 -0
  3. ivoryos_edge-0.1.1/README.md +40 -0
  4. ivoryos_edge-0.1.1/build_backend.py +66 -0
  5. ivoryos_edge-0.1.1/ivoryos_edge/__init__.py +22 -0
  6. ivoryos_edge-0.1.1/ivoryos_edge/__main__.py +4 -0
  7. ivoryos_edge-0.1.1/ivoryos_edge/agent/__init__.py +13 -0
  8. ivoryos_edge-0.1.1/ivoryos_edge/agent/ask.py +130 -0
  9. ivoryos_edge-0.1.1/ivoryos_edge/agent/chat.py +200 -0
  10. ivoryos_edge-0.1.1/ivoryos_edge/agent/deck.py +207 -0
  11. ivoryos_edge-0.1.1/ivoryos_edge/agent/history.py +160 -0
  12. ivoryos_edge-0.1.1/ivoryos_edge/agent/mcp_server.py +315 -0
  13. ivoryos_edge-0.1.1/ivoryos_edge/agent/providers.py +309 -0
  14. ivoryos_edge-0.1.1/ivoryos_edge/agent/routes.py +1129 -0
  15. ivoryos_edge-0.1.1/ivoryos_edge/agent/safety_draft.py +184 -0
  16. ivoryos_edge-0.1.1/ivoryos_edge/agent/validate.py +539 -0
  17. ivoryos_edge-0.1.1/ivoryos_edge/broker.py +268 -0
  18. ivoryos_edge-0.1.1/ivoryos_edge/campaigns.py +247 -0
  19. ivoryos_edge-0.1.1/ivoryos_edge/classic.py +220 -0
  20. ivoryos_edge-0.1.1/ivoryos_edge/cli.py +142 -0
  21. ivoryos_edge-0.1.1/ivoryos_edge/cloud_pairing.py +281 -0
  22. ivoryos_edge-0.1.1/ivoryos_edge/compatibility.py +133 -0
  23. ivoryos_edge-0.1.1/ivoryos_edge/datasheet.py +368 -0
  24. ivoryos_edge-0.1.1/ivoryos_edge/deck.py +154 -0
  25. ivoryos_edge-0.1.1/ivoryos_edge/deck_config.py +224 -0
  26. ivoryos_edge-0.1.1/ivoryos_edge/expressions.py +259 -0
  27. ivoryos_edge-0.1.1/ivoryos_edge/instance_lock.py +103 -0
  28. ivoryos_edge-0.1.1/ivoryos_edge/introspection.py +1250 -0
  29. ivoryos_edge-0.1.1/ivoryos_edge/labware.py +313 -0
  30. ivoryos_edge-0.1.1/ivoryos_edge/live.py +130 -0
  31. ivoryos_edge-0.1.1/ivoryos_edge/models.py +234 -0
  32. ivoryos_edge-0.1.1/ivoryos_edge/optimizer/ax_optimizer.py +439 -0
  33. ivoryos_edge-0.1.1/ivoryos_edge/optimizer/base_optimizer.py +112 -0
  34. ivoryos_edge-0.1.1/ivoryos_edge/optimizer/baybe_optimizer.py +400 -0
  35. ivoryos_edge-0.1.1/ivoryos_edge/optimizer/constraints.py +212 -0
  36. ivoryos_edge-0.1.1/ivoryos_edge/optimizer/nimo_optimizer.py +225 -0
  37. ivoryos_edge-0.1.1/ivoryos_edge/optimizer/registry.py +63 -0
  38. ivoryos_edge-0.1.1/ivoryos_edge/origin_guard.py +129 -0
  39. ivoryos_edge-0.1.1/ivoryos_edge/parts.py +65 -0
  40. ivoryos_edge-0.1.1/ivoryos_edge/paths.py +59 -0
  41. ivoryos_edge-0.1.1/ivoryos_edge/plugins.py +295 -0
  42. ivoryos_edge-0.1.1/ivoryos_edge/queue.py +2918 -0
  43. ivoryos_edge-0.1.1/ivoryos_edge/restart.py +127 -0
  44. ivoryos_edge-0.1.1/ivoryos_edge/run_edits.py +339 -0
  45. ivoryos_edge-0.1.1/ivoryos_edge/runtime.py +170 -0
  46. ivoryos_edge-0.1.1/ivoryos_edge/safety.py +1355 -0
  47. ivoryos_edge-0.1.1/ivoryos_edge/server.py +2788 -0
  48. ivoryos_edge-0.1.1/ivoryos_edge/ui/404/index.html +1 -0
  49. ivoryos_edge-0.1.1/ivoryos_edge/ui/404.html +1 -0
  50. ivoryos_edge-0.1.1/ivoryos_edge/ui/__next.__PAGE__.txt +29 -0
  51. ivoryos_edge-0.1.1/ivoryos_edge/ui/__next._full.txt +27 -0
  52. ivoryos_edge-0.1.1/ivoryos_edge/ui/__next._tree.txt +6 -0
  53. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/chunks/082t0g3a2sfim.js +1 -0
  54. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/chunks/09dhvumbzpqy-.js +1 -0
  55. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/chunks/0cwww4o6rzoro.js +1 -0
  56. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/chunks/0cz1d0mv5g_q7.js +1 -0
  57. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/chunks/0epiczc8lfnmb.js +1 -0
  58. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/chunks/0f7c99nchwkwr.js +1 -0
  59. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/chunks/0j1jjl1we-k53.js +68 -0
  60. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/chunks/0n79bqobc7usb.js +1 -0
  61. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/chunks/10hewadzaxemp.js +1 -0
  62. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/chunks/13bcz82-w1sy0.js +31 -0
  63. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/chunks/14p4wg13h2qxq.js +1 -0
  64. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/chunks/16b3hgm00dstg.js +1 -0
  65. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/chunks/1805184i-q2p9.js +1 -0
  66. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/chunks/1cq1h_-0ihhcs.js +1 -0
  67. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/chunks/1g8e26l1vwumd.js +1 -0
  68. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/chunks/1gjk-lnpmx4xb.js +22 -0
  69. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/chunks/1pfflhpwef9mw.js +1 -0
  70. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/chunks/1pmdgl6xlkz8v.js +1 -0
  71. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/chunks/1rl_o025_4rbw.js +1 -0
  72. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/chunks/1s73ivatypy7z.js +1 -0
  73. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/chunks/1t5_-leq_0v4f.js +1 -0
  74. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/chunks/1tgvbmmpponjq.js +1 -0
  75. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/chunks/1wlr5ikxkapwl.js +1 -0
  76. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/chunks/25tzfvp5q3oz0.js +1 -0
  77. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/chunks/2alwbtjc3tdp-.js +1 -0
  78. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/chunks/2ay1ktguq3mou.css +3 -0
  79. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/chunks/2ji0fkvck-xt_.js +1 -0
  80. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/chunks/2m8d2zm0tizrs.js +1 -0
  81. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/chunks/2trsnqcxaidz_.js +1 -0
  82. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/chunks/2ye5q0u-v564a.js +1 -0
  83. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/chunks/31ui9n6optrl0.js +1 -0
  84. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/chunks/3ddg3ngla-895.js +18 -0
  85. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/chunks/3hxkm6np690__.css +1 -0
  86. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/chunks/3x8m_ka65dm-k.js +39 -0
  87. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/chunks/448o__y5i7w-9.js +1 -0
  88. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/chunks/turbopack-1ar5u93f7rv0m.js +1 -0
  89. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/media/1bffadaabf893a1e-s.3-6t-g6q0vh0a.woff2 +0 -0
  90. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/media/2bbe8d2671613f1f-s.0k62hbripvv8p.woff2 +0 -0
  91. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/media/2c55a0e60120577a-s.0-dom-5bn10r2.woff2 +0 -0
  92. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/media/4fa387ec64143e14-s.2tuy5pz7dlieh.woff2 +0 -0
  93. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/media/53b9e256198e5412-s.390ncx5urfkfu.woff2 +0 -0
  94. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/media/5476f68d60460930-s.2uwcyprjm3xu3.woff2 +0 -0
  95. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/media/5ce348bf30bf5439-s.31988l_ccedte.woff2 +0 -0
  96. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/media/6306c77e7c8268e4-s.2dbetqa9o8jxf.woff2 +0 -0
  97. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/media/7178b3e590c64307-s.21jp631_3pja2.woff2 +0 -0
  98. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/media/797e433ab948586e-s.p.0r6juujl39pe6.woff2 +0 -0
  99. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/media/7d817b4c03b0c5f1-s.1uyisp29ctx0d.woff2 +0 -0
  100. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/media/83afe278b6a6bb3c-s.p.2bn3s6zvc0dyp.woff2 +0 -0
  101. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/media/8a480f0b521d4e75-s.1qq4vpdcun5oj.woff2 +0 -0
  102. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/media/9c72aa0f40e4eef8-s.1y4-pdgsjb-pw.woff2 +0 -0
  103. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/media/ad66f9afd8947f86-s.3lvt2whj97whp.woff2 +0 -0
  104. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/media/bbc41e54d2fcbd21-s.1rgnod-3esatf.woff2 +0 -0
  105. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/media/caa3a2e1cccd8315-s.p.0wgildi0cnwt9.woff2 +0 -0
  106. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/media/favicon.2pac4ynac9mfn.ico +0 -0
  107. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/media/fef07dbb0973bf53-s.3p2_lha1f2xer.woff2 +0 -0
  108. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/media/icon.2lb5-s1yde0r6.png +0 -0
  109. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/r5nNvy6Xg_RdE1H9usGp6/_buildManifest.js +11 -0
  110. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/r5nNvy6Xg_RdE1H9usGp6/_clientMiddlewareManifest.js +1 -0
  111. ivoryos_edge-0.1.1/ivoryos_edge/ui/_next/static/r5nNvy6Xg_RdE1H9usGp6/_ssgManifest.js +1 -0
  112. ivoryos_edge-0.1.1/ivoryos_edge/ui/_not-found/__next._full.txt +22 -0
  113. ivoryos_edge-0.1.1/ivoryos_edge/ui/_not-found/__next._not-found.__PAGE__.txt +28 -0
  114. ivoryos_edge-0.1.1/ivoryos_edge/ui/_not-found/__next._tree.txt +3 -0
  115. ivoryos_edge-0.1.1/ivoryos_edge/ui/_not-found/index.html +1 -0
  116. ivoryos_edge-0.1.1/ivoryos_edge/ui/_not-found/index.txt +22 -0
  117. ivoryos_edge-0.1.1/ivoryos_edge/ui/campaign/__next._full.txt +29 -0
  118. ivoryos_edge-0.1.1/ivoryos_edge/ui/campaign/__next._tree.txt +6 -0
  119. ivoryos_edge-0.1.1/ivoryos_edge/ui/campaign/__next.campaign.__PAGE__.txt +32 -0
  120. ivoryos_edge-0.1.1/ivoryos_edge/ui/campaign/index.html +1 -0
  121. ivoryos_edge-0.1.1/ivoryos_edge/ui/campaign/index.txt +29 -0
  122. ivoryos_edge-0.1.1/ivoryos_edge/ui/cloud/__next._full.txt +29 -0
  123. ivoryos_edge-0.1.1/ivoryos_edge/ui/cloud/__next._tree.txt +6 -0
  124. ivoryos_edge-0.1.1/ivoryos_edge/ui/cloud/__next.cloud.__PAGE__.txt +32 -0
  125. ivoryos_edge-0.1.1/ivoryos_edge/ui/cloud/index.html +1 -0
  126. ivoryos_edge-0.1.1/ivoryos_edge/ui/cloud/index.txt +29 -0
  127. ivoryos_edge-0.1.1/ivoryos_edge/ui/data/__next._full.txt +29 -0
  128. ivoryos_edge-0.1.1/ivoryos_edge/ui/data/__next._tree.txt +6 -0
  129. ivoryos_edge-0.1.1/ivoryos_edge/ui/data/__next.data.__PAGE__.txt +32 -0
  130. ivoryos_edge-0.1.1/ivoryos_edge/ui/data/index.html +1 -0
  131. ivoryos_edge-0.1.1/ivoryos_edge/ui/data/index.txt +29 -0
  132. ivoryos_edge-0.1.1/ivoryos_edge/ui/designer/__next._full.txt +29 -0
  133. ivoryos_edge-0.1.1/ivoryos_edge/ui/designer/__next._tree.txt +6 -0
  134. ivoryos_edge-0.1.1/ivoryos_edge/ui/designer/__next.designer.__PAGE__.txt +32 -0
  135. ivoryos_edge-0.1.1/ivoryos_edge/ui/designer/index.html +1 -0
  136. ivoryos_edge-0.1.1/ivoryos_edge/ui/designer/index.txt +29 -0
  137. ivoryos_edge-0.1.1/ivoryos_edge/ui/execution/__next._full.txt +29 -0
  138. ivoryos_edge-0.1.1/ivoryos_edge/ui/execution/__next._tree.txt +6 -0
  139. ivoryos_edge-0.1.1/ivoryos_edge/ui/execution/__next.execution.__PAGE__.txt +32 -0
  140. ivoryos_edge-0.1.1/ivoryos_edge/ui/execution/index.html +1 -0
  141. ivoryos_edge-0.1.1/ivoryos_edge/ui/execution/index.txt +29 -0
  142. ivoryos_edge-0.1.1/ivoryos_edge/ui/favicon.ico +0 -0
  143. ivoryos_edge-0.1.1/ivoryos_edge/ui/icon.png +0 -0
  144. ivoryos_edge-0.1.1/ivoryos_edge/ui/index.html +1 -0
  145. ivoryos_edge-0.1.1/ivoryos_edge/ui/index.txt +27 -0
  146. ivoryos_edge-0.1.1/ivoryos_edge/ui/instruments/__next._full.txt +29 -0
  147. ivoryos_edge-0.1.1/ivoryos_edge/ui/instruments/__next._tree.txt +6 -0
  148. ivoryos_edge-0.1.1/ivoryos_edge/ui/instruments/__next.instruments.__PAGE__.txt +32 -0
  149. ivoryos_edge-0.1.1/ivoryos_edge/ui/instruments/index.html +1 -0
  150. ivoryos_edge-0.1.1/ivoryos_edge/ui/instruments/index.txt +29 -0
  151. ivoryos_edge-0.1.1/ivoryos_edge/ui/ivoryos-mark.svg +86 -0
  152. ivoryos_edge-0.1.1/ivoryos_edge/ui/launcher/__next._full.txt +29 -0
  153. ivoryos_edge-0.1.1/ivoryos_edge/ui/launcher/__next._tree.txt +6 -0
  154. ivoryos_edge-0.1.1/ivoryos_edge/ui/launcher/__next.launcher.__PAGE__.txt +32 -0
  155. ivoryos_edge-0.1.1/ivoryos_edge/ui/launcher/index.html +1 -0
  156. ivoryos_edge-0.1.1/ivoryos_edge/ui/launcher/index.txt +29 -0
  157. ivoryos_edge-0.1.1/ivoryos_edge/ui/library/__next._full.txt +29 -0
  158. ivoryos_edge-0.1.1/ivoryos_edge/ui/library/__next._tree.txt +6 -0
  159. ivoryos_edge-0.1.1/ivoryos_edge/ui/library/__next.library.__PAGE__.txt +32 -0
  160. ivoryos_edge-0.1.1/ivoryos_edge/ui/library/index.html +1 -0
  161. ivoryos_edge-0.1.1/ivoryos_edge/ui/library/index.txt +29 -0
  162. ivoryos_edge-0.1.1/ivoryos_edge/ui/once/__next._full.txt +29 -0
  163. ivoryos_edge-0.1.1/ivoryos_edge/ui/once/__next._tree.txt +6 -0
  164. ivoryos_edge-0.1.1/ivoryos_edge/ui/once/__next.once.__PAGE__.txt +32 -0
  165. ivoryos_edge-0.1.1/ivoryos_edge/ui/once/index.html +1 -0
  166. ivoryos_edge-0.1.1/ivoryos_edge/ui/once/index.txt +29 -0
  167. ivoryos_edge-0.1.1/ivoryos_edge/ui/optimize/__next._full.txt +29 -0
  168. ivoryos_edge-0.1.1/ivoryos_edge/ui/optimize/__next._tree.txt +6 -0
  169. ivoryos_edge-0.1.1/ivoryos_edge/ui/optimize/__next.optimize.__PAGE__.txt +32 -0
  170. ivoryos_edge-0.1.1/ivoryos_edge/ui/optimize/index.html +1 -0
  171. ivoryos_edge-0.1.1/ivoryos_edge/ui/optimize/index.txt +29 -0
  172. ivoryos_edge-0.1.1/ivoryos_edge/ui/plugin/__next._full.txt +29 -0
  173. ivoryos_edge-0.1.1/ivoryos_edge/ui/plugin/__next._tree.txt +6 -0
  174. ivoryos_edge-0.1.1/ivoryos_edge/ui/plugin/__next.plugin.__PAGE__.txt +32 -0
  175. ivoryos_edge-0.1.1/ivoryos_edge/ui/plugin/index.html +1 -0
  176. ivoryos_edge-0.1.1/ivoryos_edge/ui/plugin/index.txt +29 -0
  177. ivoryos_edge-0.1.1/ivoryos_edge/ui/queue/__next._full.txt +29 -0
  178. ivoryos_edge-0.1.1/ivoryos_edge/ui/queue/__next._tree.txt +6 -0
  179. ivoryos_edge-0.1.1/ivoryos_edge/ui/queue/__next.queue.__PAGE__.txt +32 -0
  180. ivoryos_edge-0.1.1/ivoryos_edge/ui/queue/index.html +1 -0
  181. ivoryos_edge-0.1.1/ivoryos_edge/ui/queue/index.txt +29 -0
  182. ivoryos_edge-0.1.1/ivoryos_edge/ui/safety/__next._full.txt +29 -0
  183. ivoryos_edge-0.1.1/ivoryos_edge/ui/safety/__next._tree.txt +6 -0
  184. ivoryos_edge-0.1.1/ivoryos_edge/ui/safety/__next.safety.__PAGE__.txt +32 -0
  185. ivoryos_edge-0.1.1/ivoryos_edge/ui/safety/index.html +1 -0
  186. ivoryos_edge-0.1.1/ivoryos_edge/ui/safety/index.txt +29 -0
  187. ivoryos_edge-0.1.1/ivoryos_edge/ui/settings/__next._full.txt +29 -0
  188. ivoryos_edge-0.1.1/ivoryos_edge/ui/settings/__next._tree.txt +6 -0
  189. ivoryos_edge-0.1.1/ivoryos_edge/ui/settings/__next.settings.__PAGE__.txt +32 -0
  190. ivoryos_edge-0.1.1/ivoryos_edge/ui/settings/index.html +1 -0
  191. ivoryos_edge-0.1.1/ivoryos_edge/ui/settings/index.txt +29 -0
  192. ivoryos_edge-0.1.1/ivoryos_edge/ui/stages/__next._full.txt +29 -0
  193. ivoryos_edge-0.1.1/ivoryos_edge/ui/stages/__next._tree.txt +6 -0
  194. ivoryos_edge-0.1.1/ivoryos_edge/ui/stages/__next.stages.__PAGE__.txt +32 -0
  195. ivoryos_edge-0.1.1/ivoryos_edge/ui/stages/index.html +1 -0
  196. ivoryos_edge-0.1.1/ivoryos_edge/ui/stages/index.txt +29 -0
  197. ivoryos_edge-0.1.1/ivoryos_edge/units.py +37 -0
  198. ivoryos_edge-0.1.1/ivoryos_edge/workflows.py +882 -0
  199. ivoryos_edge-0.1.1/ivoryos_edge.egg-info/PKG-INFO +70 -0
  200. ivoryos_edge-0.1.1/ivoryos_edge.egg-info/SOURCES.txt +204 -0
  201. ivoryos_edge-0.1.1/ivoryos_edge.egg-info/dependency_links.txt +1 -0
  202. ivoryos_edge-0.1.1/ivoryos_edge.egg-info/entry_points.txt +2 -0
  203. ivoryos_edge-0.1.1/ivoryos_edge.egg-info/requires.txt +22 -0
  204. ivoryos_edge-0.1.1/ivoryos_edge.egg-info/top_level.txt +1 -0
  205. ivoryos_edge-0.1.1/pyproject.toml +62 -0
  206. ivoryos_edge-0.1.1/setup.cfg +4 -0
@@ -0,0 +1,2 @@
1
+ # The build backend itself, so a wheel can be built from the sdist.
2
+ include build_backend.py
@@ -0,0 +1,70 @@
1
+ Metadata-Version: 2.4
2
+ Name: ivoryos-edge
3
+ Version: 0.1.1
4
+ Summary: IvoryOS Edge Server
5
+ License-Expression: Apache-2.0
6
+ Project-URL: Homepage, https://github.com/ivoryos-ai/ivoryos-next
7
+ Project-URL: Documentation, https://ivoryos-ai.github.io/ivoryos-next/
8
+ Project-URL: Issues, https://github.com/ivoryos-ai/ivoryos-next/issues
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: fastapi
12
+ Requires-Dist: uvicorn
13
+ Requires-Dist: python-socketio[client]
14
+ Requires-Dist: pydantic
15
+ Requires-Dist: python-dotenv
16
+ Requires-Dist: aiohttp
17
+ Requires-Dist: sqlalchemy[asyncio]
18
+ Requires-Dist: aiosqlite
19
+ Requires-Dist: paho-mqtt
20
+ Requires-Dist: httpx>=0.28.1
21
+ Requires-Dist: pandas
22
+ Provides-Extra: test
23
+ Requires-Dist: pytest; extra == "test"
24
+ Requires-Dist: pytest-asyncio; extra == "test"
25
+ Requires-Dist: httpx; extra == "test"
26
+ Provides-Extra: mcp
27
+ Requires-Dist: mcp>=1.10; extra == "mcp"
28
+ Provides-Extra: claude
29
+ Requires-Dist: anthropic>=1.0; extra == "claude"
30
+
31
+ # ivoryos-edge
32
+
33
+ The IvoryOS edge: it runs next to your instruments, reads the Python classes that drive them, and
34
+ serves a web page to design, run and optimize experiments with them. Every method becomes a step
35
+ you can drag into a workflow, and every argument a field of the right type. Runs, results and
36
+ workflows are kept on that computer.
37
+
38
+ Most people use it through the [IvoryOS desktop app](https://github.com/ivoryos-ai/ivoryos-next/releases),
39
+ which installs this package itself. Install it on its own to run a deck from your own Python
40
+ environment, such as one your editor uses.
41
+
42
+ ## Install
43
+
44
+ ```bash
45
+ pip install ivoryos-edge
46
+ ```
47
+
48
+ Python 3.10 or newer. The web interface is inside the package; nothing else is needed.
49
+
50
+ ## Run your instruments
51
+
52
+ End a script that creates your instrument objects with `ivoryos_edge.run(__name__)`:
53
+
54
+ ```python
55
+ import ivoryos_edge
56
+ from my_lab import SyringePump
57
+
58
+ pump = SyringePump(port="COM3") # each object becomes an instrument
59
+
60
+ ivoryos_edge.run(__name__) # http://localhost:8080
61
+ ```
62
+
63
+ Then open `http://localhost:8080` in a browser on that computer. A deck can also be a JSON file,
64
+ run with `ivoryos-edge --deck deck.json`.
65
+
66
+ ## Links
67
+
68
+ - [Documentation](https://ivoryos-ai.github.io/ivoryos-next/)
69
+ - [Automation Hub](https://ivoryos.ai): drivers, platforms and workflow templates
70
+ - [Source and issues](https://github.com/ivoryos-ai/ivoryos-next)
@@ -0,0 +1,40 @@
1
+ # ivoryos-edge
2
+
3
+ The IvoryOS edge: it runs next to your instruments, reads the Python classes that drive them, and
4
+ serves a web page to design, run and optimize experiments with them. Every method becomes a step
5
+ you can drag into a workflow, and every argument a field of the right type. Runs, results and
6
+ workflows are kept on that computer.
7
+
8
+ Most people use it through the [IvoryOS desktop app](https://github.com/ivoryos-ai/ivoryos-next/releases),
9
+ which installs this package itself. Install it on its own to run a deck from your own Python
10
+ environment, such as one your editor uses.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pip install ivoryos-edge
16
+ ```
17
+
18
+ Python 3.10 or newer. The web interface is inside the package; nothing else is needed.
19
+
20
+ ## Run your instruments
21
+
22
+ End a script that creates your instrument objects with `ivoryos_edge.run(__name__)`:
23
+
24
+ ```python
25
+ import ivoryos_edge
26
+ from my_lab import SyringePump
27
+
28
+ pump = SyringePump(port="COM3") # each object becomes an instrument
29
+
30
+ ivoryos_edge.run(__name__) # http://localhost:8080
31
+ ```
32
+
33
+ Then open `http://localhost:8080` in a browser on that computer. A deck can also be a JSON file,
34
+ run with `ivoryos-edge --deck deck.json`.
35
+
36
+ ## Links
37
+
38
+ - [Documentation](https://ivoryos-ai.github.io/ivoryos-next/)
39
+ - [Automation Hub](https://ivoryos.ai): drivers, platforms and workflow templates
40
+ - [Source and issues](https://github.com/ivoryos-ai/ivoryos-next)
@@ -0,0 +1,66 @@
1
+ """The build backend of ivoryos-edge: setuptools, refusing a wheel or sdist with no web UI in it.
2
+
3
+ A wheel or sdist is the edge as people install it (`pip install ivoryos-edge`, the desktop app's
4
+ bundled wheel), and it serves the pages it carries in `ivoryos_edge/ui/`, which are the same
5
+ version as its API. That folder is a build output, not source: scripts/build-edge.js at the
6
+ repository root builds the frontend, copies frontend/out into it and builds. Building without it
7
+ used to succeed and gave an edge that starts with an API and no pages, so it is refused here, and
8
+ the built file is checked to hold the pages too (a package-data rule that stops matching would
9
+ otherwise drop them silently).
10
+
11
+ An editable install (`pip install -e`, a source checkout in development) needs no copy: the edge
12
+ serves frontend/out from the checkout (`_frontend_dirs` in ivoryos_edge/server.py).
13
+ """
14
+ import os
15
+ import shutil
16
+ import tarfile
17
+ import zipfile
18
+
19
+ from setuptools import build_meta as _setuptools
20
+ from setuptools.build_meta import * # noqa: F401,F403 every other hook, editable installs included
21
+
22
+ _UI_INDEX = "ivoryos_edge/ui/index.html"
23
+
24
+
25
+ def _refuse(message):
26
+ raise RuntimeError(
27
+ f"{message}\nA wheel or sdist of ivoryos-edge must carry the web UI, or the edge it installs "
28
+ "starts with no pages. Build it with `npm run build:edge` from the repository root, which "
29
+ "builds frontend/ and copies frontend/out into ivoryos_edge/ui/ first. (In development, "
30
+ "`pip install -e ./edge_server` needs no copy: it serves frontend/out from the checkout.)"
31
+ )
32
+
33
+
34
+ def _require_ui():
35
+ if not os.path.isfile(_UI_INDEX):
36
+ _refuse(f"No web UI to package: {os.path.abspath(_UI_INDEX)} does not exist.")
37
+
38
+
39
+ def _check_built(path, names):
40
+ if not any(name == _UI_INDEX or name.endswith("/" + _UI_INDEX) for name in names):
41
+ os.remove(path)
42
+ _refuse(f"{os.path.basename(path)} came out without {_UI_INDEX}, so it was removed. "
43
+ "Check [tool.setuptools.package-data] in edge_server/pyproject.toml.")
44
+
45
+
46
+ def build_wheel(wheel_directory, config_settings=None, metadata_directory=None):
47
+ _require_ui()
48
+ # setuptools copies the package into build/lib and packs everything there, earlier builds'
49
+ # files too: the UI's script names change with every frontend build, so old ones would pile up.
50
+ shutil.rmtree(os.path.join("build", "lib", "ivoryos_edge", "ui"), ignore_errors=True)
51
+ name = _setuptools.build_wheel(wheel_directory, config_settings, metadata_directory)
52
+ path = os.path.join(wheel_directory, name)
53
+ with zipfile.ZipFile(path) as wheel:
54
+ names = wheel.namelist()
55
+ _check_built(path, names)
56
+ return name
57
+
58
+
59
+ def build_sdist(sdist_directory, config_settings=None):
60
+ _require_ui()
61
+ name = _setuptools.build_sdist(sdist_directory, config_settings)
62
+ path = os.path.join(sdist_directory, name)
63
+ with tarfile.open(path) as sdist:
64
+ names = sdist.getnames()
65
+ _check_built(path, names)
66
+ return name
@@ -0,0 +1,22 @@
1
+ """IvoryOS edge server.
2
+
3
+ `ivoryos_edge.run(__name__)` is resolved lazily (PEP 562) rather than imported here. Importing the
4
+ server fixes every data path (see paths.py) and creates the database engine, and the command-line
5
+ entry point has to set `IVORYOS_DATA_DIR` before that happens -- which it cannot do if merely
6
+ importing the package has already imported the server.
7
+ """
8
+
9
+ __all__ = ["run", "Unit"]
10
+
11
+
12
+ def __getattr__(name):
13
+ if name == "run":
14
+ from .server import run
15
+ return run
16
+ if name == "Unit":
17
+ # `from ivoryos_edge import Unit`, for a driver's annotations (units.py). Lazy like `run`,
18
+ # for the opposite reason: units.py imports nothing, and a driver module importing it
19
+ # must not drag the server in.
20
+ from .units import Unit
21
+ return Unit
22
+ raise AttributeError(f"module 'ivoryos_edge' has no attribute {name!r}")
@@ -0,0 +1,4 @@
1
+ """`python -m ivoryos_edge`: see cli.py."""
2
+ from .cli import main
3
+
4
+ main()
@@ -0,0 +1,13 @@
1
+ """The agent tool layer: what an assistant is allowed to know and to ask for.
2
+
3
+ One layer, two surfaces. `routes.py` publishes it over HTTP under /api/agent; the MCP server
4
+ (`ivoryos_edge.agent.mcp_server`) is a thin stdio process that calls those same endpoints, and
5
+ the Designer's chat panel calls them from the browser. Adding a third surface, or swapping which
6
+ model is driving, should never mean reimplementing what a tool *does* — which is the whole
7
+ reason this is a module and not a pile of route handlers.
8
+ """
9
+
10
+ from ivoryos_edge.agent.deck import describe_deck, describe_method
11
+ from ivoryos_edge.agent.validate import validate_body, summarise
12
+
13
+ __all__ = ["describe_deck", "describe_method", "validate_body", "summarise"]
@@ -0,0 +1,130 @@
1
+ """Answering questions about this lab's runs and deck, from its own records.
2
+
3
+ Small local models are unreliable at a tool-calling protocol and fine at emitting one JSON object
4
+ (see providers.py), so tools are offered the same way workflows are written: each reply is one
5
+ JSON object, either a tool to run or the answer. The tool runs here, its result goes back as the
6
+ next message, and after a few rounds the model must answer with what it has.
7
+
8
+ Read-only by construction: the tools are history.search / read / compare and nothing else. An
9
+ answer that should change something (a workflow, a run, a safety rule) is the other modes' job,
10
+ which file proposals for a person to accept.
11
+ """
12
+
13
+ import json
14
+ from typing import Any, Awaitable, Callable, Dict, List, Optional
15
+
16
+ from ivoryos_edge.agent import history
17
+ from ivoryos_edge.agent.providers import extract_json_object
18
+
19
+ MAX_TOOL_CALLS = 5
20
+ MAX_RESULT_CHARS = 12000
21
+
22
+ SYSTEM_PROMPT = """You answer questions about this lab's experiments, using its own records.
23
+
24
+ You can look things up. Reply with exactly one JSON object and nothing else, either a lookup:
25
+
26
+ {"tool": "search_runs", "args": {"q": "<words in the run's name, columns or instruments>", "status": "<completed, error, cancelled or all>", "limit": 20}}
27
+ {"tool": "run_table", "args": {"run_id": <id>}}
28
+ {"tool": "compare_runs", "args": {"run_ids": [<id>, <id>], "columns": ["<column>"]}}
29
+
30
+ or, when you can answer:
31
+
32
+ {"answer": "<your answer, in plain language; short markdown lists and tables are fine>", "runs": [<ids of the runs your answer is about>]}
33
+
34
+ How to work:
35
+ - run_table gives one run's columns, rows and per-column numbers (n, min, max, mean, and the best
36
+ row for an optimization objective). compare_runs computes the same across runs. Use those
37
+ numbers; do not compute statistics yourself from rows.
38
+ - Every number in your answer must come from a lookup result in this conversation. If the records
39
+ do not hold what was asked, say so plainly and say what they do hold.
40
+ - Name runs as "<name> (#<id>)" so the person can open them.
41
+ - A column ending in "(objective)" is what an optimization was trying to improve.
42
+ - Prefer one or two lookups. You may make at most %d.""" % MAX_TOOL_CALLS
43
+
44
+
45
+ def _clip_json(value: Any) -> str:
46
+ text = json.dumps(value, default=str)
47
+ if len(text) > MAX_RESULT_CHARS:
48
+ text = text[:MAX_RESULT_CHARS] + ' ... (cut: ask for fewer rows or one run at a time)'
49
+ return text
50
+
51
+
52
+ async def _run_tool(queue_manager, name: str, args: Dict[str, Any]) -> Dict[str, Any]:
53
+ if name == "search_runs":
54
+ return await history.search(queue_manager, q=str(args.get("q") or ""), status=str(args.get("status") or "all"),
55
+ limit=args.get("limit") or 20, offset=args.get("offset") or 0,
56
+ sort=str(args.get("sort") or "newest"))
57
+ if name == "run_table":
58
+ table = await history.read(queue_manager, args.get("run_id"))
59
+ return table or {"error": f"No run #{args.get('run_id')}."}
60
+ if name == "compare_runs":
61
+ columns = args.get("columns")
62
+ return await history.compare(queue_manager, args.get("run_ids") or [],
63
+ [str(c) for c in columns] if isinstance(columns, list) and columns else None)
64
+ return {"error": f"There is no tool called '{name}'. Use search_runs, run_table or compare_runs."}
65
+
66
+
67
+ async def answer(provider, queue_manager, question: str, history_turns: Optional[List[dict]] = None,
68
+ page_context: str = "", deck_names: Optional[List[str]] = None,
69
+ on_progress: Optional[Callable[[dict], Awaitable[None]]] = None):
70
+ """Run the look-up-then-answer loop. Returns (result, transcript); result is
71
+ {answer, runs, lookups: [{tool, args}], ok}."""
72
+ async def report(**event):
73
+ if on_progress is not None:
74
+ await on_progress(event)
75
+
76
+ recent = await history.search(queue_manager, limit=15)
77
+ context = ["The 15 most recent runs on this deck (newest first):\n" + _clip_json(recent)]
78
+ if deck_names:
79
+ context.append("Instruments on this deck: " + ", ".join(deck_names))
80
+ if page_context:
81
+ context.append("Where the person is in the app right now: " + page_context)
82
+ messages: List[dict] = [
83
+ {"role": "user", "content": "\n\n".join(context)},
84
+ {"role": "assistant", "content": "Understood. I will reply with one JSON object: a lookup or the answer."},
85
+ ]
86
+ for turn in history_turns or []:
87
+ if turn.get("role") in ("user", "assistant") and turn.get("content"):
88
+ messages.append({"role": turn["role"], "content": str(turn["content"])[:8000]})
89
+ messages.append({"role": "user", "content": question})
90
+
91
+ lookups: List[dict] = []
92
+ transcript: List[dict] = []
93
+ await report(phase="thinking")
94
+ for turn in range(MAX_TOOL_CALLS + 2):
95
+ raw = await provider.complete(SYSTEM_PROMPT, messages, json_mode=True)
96
+ transcript.append({"turn": turn, "raw": raw})
97
+ try:
98
+ reply = extract_json_object(raw)
99
+ except ValueError:
100
+ messages += [{"role": "assistant", "content": raw[:4000]},
101
+ {"role": "user", "content": "That was not one JSON object. Reply with a lookup or an answer, as JSON only."}]
102
+ continue
103
+
104
+ if reply.get("answer") is not None:
105
+ runs = [r for r in (reply.get("runs") or []) if isinstance(r, int)]
106
+ await report(phase="answered")
107
+ return {"answer": str(reply["answer"]).strip(), "runs": runs, "lookups": lookups, "ok": True}, transcript
108
+
109
+ tool = str(reply.get("tool") or "")
110
+ if not tool:
111
+ messages += [{"role": "assistant", "content": raw[:4000]},
112
+ {"role": "user", "content": 'Reply with {"tool": ..., "args": {...}} or {"answer": ..., "runs": [...]}.'}]
113
+ continue
114
+ if len(lookups) >= MAX_TOOL_CALLS:
115
+ messages += [{"role": "assistant", "content": raw[:4000]},
116
+ {"role": "user", "content": "No more lookups. Answer now with what you have, and say what is missing."}]
117
+ continue
118
+ args = reply.get("args") if isinstance(reply.get("args"), dict) else {}
119
+ lookups.append({"tool": tool, "args": args})
120
+ await report(phase="looking_up", tool=tool, args=args)
121
+ try:
122
+ result = await _run_tool(queue_manager, tool, args)
123
+ except Exception as e: # a bad argument from the model is its to fix, not a failure here
124
+ result = {"error": str(e)}
125
+ messages += [{"role": "assistant", "content": json.dumps(reply)[:4000]},
126
+ {"role": "user", "content": f"Result of {tool}:\n{_clip_json(result)}"}]
127
+
128
+ await report(phase="gave_up")
129
+ return {"answer": "I could not get to an answer from the records. Try asking about one run, or name the runs to compare.",
130
+ "runs": [], "lookups": lookups, "ok": False}, transcript
@@ -0,0 +1,200 @@
1
+ """Turning a paragraph of protocol into a reviewable workflow.
2
+
3
+ The loop is: describe the deck, ask for one JSON workflow body, validate it against that same
4
+ deck, and hand the errors back to the model to fix — up to a small number of attempts. The
5
+ retry is what makes this usable with a local model: a 8B model asked cold gets an argument name
6
+ or a unit wrong perhaps half the time, and gets it right when told precisely what was wrong,
7
+ because `validate_body`'s messages name the offending field and list the legal alternatives.
8
+
9
+ What comes out is never applied. It is filed as a proposal for the scientist to accept, so the
10
+ worst case of a bad translation is a rejected diff rather than a wrong reaction.
11
+ """
12
+
13
+ import json
14
+
15
+ from ivoryos_edge.agent.deck import describe_deck
16
+ from ivoryos_edge.agent.providers import ProviderError, extract_json_object
17
+ from ivoryos_edge.agent.validate import validate_body, summarise
18
+
19
+ MAX_ATTEMPTS = 3
20
+
21
+ SYSTEM_PROMPT = """You translate laboratory protocols into IvoryOS workflows.
22
+
23
+ You are given the exact set of instruments and methods available on this lab's deck. You must
24
+ use only those. Never invent an instrument, a method or an argument name: if the protocol asks
25
+ for something the deck cannot do, say so in your summary and leave that step out rather than
26
+ approximating it with a method that does something else.
27
+
28
+ Reply with a single JSON object and nothing else:
29
+
30
+ {
31
+ "summary": "<plain language: what this does, and for an edit, what you changed and why>",
32
+ "body": {
33
+ "name": "<short name>",
34
+ "description": "<one line>",
35
+ "prep": [ ...steps... ],
36
+ "script": [ ...steps... ],
37
+ "cleanup": [ ...steps... ]
38
+ },
39
+ "questions": ["<anything the protocol left ambiguous that a person must decide>"]
40
+ }
41
+
42
+ A step is {"instrument": "<name>", "action": "<method>", "args": {"<arg>": <value>}}.
43
+
44
+ Rules that matter:
45
+ - Numbers are bare numbers. Write 65, never "65 C" or "65 degrees". A parameter's `unit` says
46
+ what the number is in: a volume whose unit is mL takes 2.5 for 2.5 mL, never 2500.
47
+ - `prep` runs once at the start, `script` is the part repeated per sample or per optimization
48
+ trial, and `cleanup` runs once at the end. If the protocol does not distinguish them, put
49
+ everything in `script`.
50
+ - To save a result for a later step or as an optimization objective, add to that step:
51
+ "return": "name", "return_bindings": [{"path": "<a save_from value from the deck>", "var": "name"}].
52
+ Use the exact `save_from` strings the deck lists. Only numeric results can be optimized.
53
+ - To use a saved value later, write it as a string "#name" in an argument.
54
+ - A value the protocol leaves to the operator should be "#name" and listed in `questions`,
55
+ or read with a Flow_Control User_Input step.
56
+ - To compute a value from earlier ones (a counter, a dilution), use a Flow_Control Set_Variable
57
+ step: "args": {"variable_name": "dilution", "value": "volume / 2"}, earlier variables by bare
58
+ name in "value", then "#dilution" in later arguments.
59
+ - Every Flow_Control "If" needs a matching "End_If", and every "While" a matching "End_While".
60
+ - Prefer fewer, correct steps. Do not invent safety, washing or calibration steps the protocol
61
+ does not mention.
62
+
63
+ If you are editing an existing workflow, keep every step the scientist did not ask you to
64
+ change, exactly as it was."""
65
+
66
+
67
+ def _deck_prompt(schema, workflows):
68
+ return (
69
+ "This lab's deck, as JSON. These are the only instruments and methods that exist:\n\n"
70
+ + json.dumps(describe_deck(schema, workflows=workflows), indent=1)
71
+ )
72
+
73
+
74
+ def _existing_prompt(name, body):
75
+ return (
76
+ f"You are editing the saved workflow '{name}'. Its current body is:\n\n"
77
+ + json.dumps(body, indent=1)
78
+ + "\n\nReturn the complete new body, not a patch."
79
+ )
80
+
81
+
82
+ def _retry_prompt(issues):
83
+ errors = [i for i in issues if i["severity"] == "error"]
84
+ lines = []
85
+ for issue in errors:
86
+ line = f"- {issue['where']}: {issue['message']}"
87
+ if issue.get("hint"):
88
+ line += f" ({issue['hint']})"
89
+ lines.append(line)
90
+ return (
91
+ "That workflow does not validate against the deck. Fix exactly these problems and "
92
+ "return the corrected JSON object:\n\n" + "\n".join(lines)
93
+ )
94
+
95
+
96
+ async def translate(provider, schema, user_message, history=None, workflows=(),
97
+ existing_name=None, existing_body=None, max_attempts=MAX_ATTEMPTS,
98
+ on_progress=None, resolve_workflow=None, check_fields=None):
99
+ """Run the translate-validate-correct loop.
100
+
101
+ Returns (result, transcript) where result is {summary, body, questions, issues, ok,
102
+ attempts} and transcript is the raw exchange, kept so a failure can be shown to the
103
+ scientist rather than swallowed.
104
+
105
+ `on_progress` is awaited with a dict at each phase. The interesting part of this loop is not
106
+ the tokens, it is the check-and-correct cycle — "wrote 9 steps, two were wrong, fixing" is
107
+ what tells a waiting scientist the thing is working and roughly how well. A local model
108
+ takes tens of seconds per attempt, which is far too long to show nothing.
109
+ """
110
+ async def report(**event):
111
+ if on_progress is not None:
112
+ await on_progress(event)
113
+ messages = []
114
+ context = [_deck_prompt(schema, workflows)]
115
+ if existing_body:
116
+ context.append(_existing_prompt(existing_name, existing_body))
117
+ messages.append({"role": "user", "content": "\n\n".join(context)})
118
+ messages.append({
119
+ "role": "assistant",
120
+ "content": "Understood. I will use only those instruments and methods, and reply with a single JSON object.",
121
+ })
122
+
123
+ for turn in (history or []):
124
+ role = turn.get("role")
125
+ content = turn.get("content")
126
+ if role in ("user", "assistant") and content:
127
+ messages.append({"role": role, "content": str(content)[:20000]})
128
+
129
+ messages.append({"role": "user", "content": user_message})
130
+
131
+ await report(phase="reading_deck", instruments=len(schema),
132
+ editing=existing_name or None)
133
+
134
+ transcript = []
135
+ last_error = None
136
+ for attempt in range(1, max_attempts + 1):
137
+ await report(phase="drafting", attempt=attempt, max_attempts=max_attempts)
138
+ raw = await provider.complete(SYSTEM_PROMPT, messages, json_mode=True)
139
+ transcript.append({"attempt": attempt, "raw": raw})
140
+
141
+ try:
142
+ parsed = extract_json_object(raw)
143
+ except ValueError as e:
144
+ last_error = str(e)
145
+ await report(phase="unreadable", attempt=attempt, detail=str(e))
146
+ messages.append({"role": "assistant", "content": raw[:4000]})
147
+ messages.append({
148
+ "role": "user",
149
+ "content": "That was not valid JSON. Reply with a single JSON object and nothing else.",
150
+ })
151
+ continue
152
+
153
+ body = parsed.get("body")
154
+ if not isinstance(body, dict):
155
+ last_error = "The reply had no 'body' object."
156
+ await report(phase="unreadable", attempt=attempt, detail=last_error)
157
+ messages.append({"role": "assistant", "content": raw[:4000]})
158
+ messages.append({
159
+ "role": "user",
160
+ "content": "The JSON object must contain a 'body' with prep/script/cleanup arrays.",
161
+ })
162
+ continue
163
+
164
+ body.setdefault("name", existing_name or parsed.get("name") or "Untitled protocol")
165
+ for phase in ("prep", "script", "cleanup"):
166
+ body.setdefault(phase, [])
167
+
168
+ step_count = sum(len(body.get(phase) or []) for phase in ("prep", "script", "cleanup"))
169
+ await report(phase="validating", attempt=attempt, steps=step_count,
170
+ name=body.get("name"))
171
+
172
+ issues = validate_body(body, schema, workflows, resolve_workflow, check_fields)
173
+ errors = [i for i in issues if i["severity"] == "error"]
174
+ result = {
175
+ "summary": str(parsed.get("summary") or "").strip(),
176
+ "body": body,
177
+ "questions": [str(q) for q in (parsed.get("questions") or [])][:10],
178
+ "issues": issues,
179
+ "validation_summary": summarise(issues),
180
+ "ok": not errors,
181
+ "attempts": attempt,
182
+ }
183
+ if not errors:
184
+ await report(phase="valid", attempt=attempt, steps=step_count)
185
+ return result, transcript
186
+
187
+ await report(phase="found_problems", attempt=attempt,
188
+ errors=[f"{i['where']}: {i['message']}" for i in errors])
189
+
190
+ if attempt == max_attempts:
191
+ await report(phase="gave_up", attempt=attempt, remaining=len(errors))
192
+ # Out of attempts, but the draft still goes back: a workflow with two bad arguments
193
+ # and the errors named against it is a far better starting point for the scientist
194
+ # than an apology, and the Designer shows the issues inline.
195
+ return result, transcript
196
+
197
+ messages.append({"role": "assistant", "content": json.dumps(parsed)[:8000]})
198
+ messages.append({"role": "user", "content": _retry_prompt(issues)})
199
+
200
+ raise ProviderError(last_error or "The model did not return a usable workflow.")