prompt_objects 0.6.0 → 0.7.0

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 (291) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +126 -0
  3. data/README.md +93 -22
  4. data/exe/poop +5 -0
  5. data/exe/prompt_objects +1 -1108
  6. data/exe/prompt_objects_mcp +5 -3
  7. data/lib/prompt_objects/capability.rb +33 -0
  8. data/lib/prompt_objects/cli/diagnostics_command.rb +69 -0
  9. data/lib/prompt_objects/cli/env_command.rb +435 -0
  10. data/lib/prompt_objects/cli/events_command.rb +145 -0
  11. data/lib/prompt_objects/cli/explore_command.rb +155 -0
  12. data/lib/prompt_objects/cli/helpers.rb +91 -0
  13. data/lib/prompt_objects/cli/message_command.rb +216 -0
  14. data/lib/prompt_objects/cli/repl_command.rb +366 -0
  15. data/lib/prompt_objects/cli/serve_command.rb +162 -0
  16. data/lib/prompt_objects/cli.rb +64 -422
  17. data/lib/prompt_objects/connectors/base.rb +16 -29
  18. data/lib/prompt_objects/connectors/mcp.rb +82 -98
  19. data/lib/prompt_objects/domain/records.rb +45 -0
  20. data/lib/prompt_objects/environment/exporter.rb +5 -4
  21. data/lib/prompt_objects/environment/git.rb +21 -1
  22. data/lib/prompt_objects/environment/importer.rb +5 -4
  23. data/lib/prompt_objects/environment/manager.rb +44 -6
  24. data/lib/prompt_objects/environment/manifest.rb +26 -4
  25. data/lib/prompt_objects/environment.rb +1337 -20
  26. data/lib/prompt_objects/execution/blocking_executor.rb +88 -0
  27. data/lib/prompt_objects/execution/context.rb +137 -0
  28. data/lib/prompt_objects/execution/contracts.rb +59 -0
  29. data/lib/prompt_objects/execution/permit_pool.rb +49 -0
  30. data/lib/prompt_objects/execution/policy.rb +47 -0
  31. data/lib/prompt_objects/execution/resource_lock_manager.rb +63 -0
  32. data/lib/prompt_objects/execution/runner.rb +498 -0
  33. data/lib/prompt_objects/execution/scheduler.rb +325 -0
  34. data/lib/prompt_objects/llm/factory.rb +4 -4
  35. data/lib/prompt_objects/llm/gemini_adapter.rb +25 -8
  36. data/lib/prompt_objects/llm/openai_adapter.rb +9 -1
  37. data/lib/prompt_objects/llm/pricing.rb +9 -2
  38. data/lib/prompt_objects/llm/response.rb +9 -4
  39. data/lib/prompt_objects/loader.rb +28 -1
  40. data/lib/prompt_objects/mcp/server.rb +6 -3
  41. data/lib/prompt_objects/mcp/tools/get_conversation.rb +15 -11
  42. data/lib/prompt_objects/mcp/tools/get_pending_requests.rb +2 -2
  43. data/lib/prompt_objects/mcp/tools/respond_to_request.rb +3 -3
  44. data/lib/prompt_objects/mcp/tools/send_message.rb +42 -32
  45. data/lib/prompt_objects/operations/diagnostics.rb +115 -0
  46. data/lib/prompt_objects/operations/retention.rb +75 -0
  47. data/lib/prompt_objects/operations/run_trace_builder.rb +152 -0
  48. data/lib/prompt_objects/primitives/http_get.rb +2 -0
  49. data/lib/prompt_objects/primitives/list_files.rb +2 -0
  50. data/lib/prompt_objects/primitives/read_file.rb +2 -0
  51. data/lib/prompt_objects/primitives/think.rb +36 -0
  52. data/lib/prompt_objects/primitives/write_file.rb +9 -0
  53. data/lib/prompt_objects/prompt_object.rb +171 -519
  54. data/lib/prompt_objects/repositories/artifact_repository.rb +109 -0
  55. data/lib/prompt_objects/repositories/base_repository.rb +37 -0
  56. data/lib/prompt_objects/repositories/definition_repository.rb +36 -0
  57. data/lib/prompt_objects/repositories/env_data_repository.rb +103 -0
  58. data/lib/prompt_objects/repositories/event_repository.rb +105 -0
  59. data/lib/prompt_objects/repositories/message_repository.rb +73 -0
  60. data/lib/prompt_objects/repositories/run_repository.rb +254 -0
  61. data/lib/prompt_objects/repositories/thread_repository.rb +87 -0
  62. data/lib/prompt_objects/repositories/tool_execution_repository.rb +88 -0
  63. data/lib/prompt_objects/repositories/workspace_session_repository.rb +61 -0
  64. data/lib/prompt_objects/server/api/routes.rb +549 -53
  65. data/lib/prompt_objects/server/app.rb +133 -14
  66. data/lib/prompt_objects/server/file_watcher.rb +9 -0
  67. data/lib/prompt_objects/server/public/assets/index-BjKlODDD.js +90 -0
  68. data/lib/prompt_objects/server/public/assets/index-w3fpIb5c.css +1 -0
  69. data/lib/prompt_objects/server/public/index.html +2 -2
  70. data/lib/prompt_objects/server/websocket_handler.rb +202 -227
  71. data/lib/prompt_objects/server.rb +170 -1
  72. data/lib/prompt_objects/service.rb +129 -0
  73. data/lib/prompt_objects/session/store.rb +600 -13
  74. data/lib/prompt_objects/universal/add_capability.rb +1 -1
  75. data/lib/prompt_objects/universal/create_capability.rb +4 -2
  76. data/lib/prompt_objects/universal/delete_env_data.rb +23 -15
  77. data/lib/prompt_objects/universal/delete_primitive.rb +1 -1
  78. data/lib/prompt_objects/universal/dispatch_po.rb +54 -0
  79. data/lib/prompt_objects/universal/get_env_data.rb +8 -14
  80. data/lib/prompt_objects/universal/get_run_status.rb +30 -0
  81. data/lib/prompt_objects/universal/join_runs.rb +35 -0
  82. data/lib/prompt_objects/universal/list_env_data.rb +10 -14
  83. data/lib/prompt_objects/universal/modify_primitive.rb +1 -1
  84. data/lib/prompt_objects/universal/render_artifact.rb +91 -0
  85. data/lib/prompt_objects/universal/request_primitive.rb +1 -1
  86. data/lib/prompt_objects/universal/{store_env_data.rb → set_env_data.rb} +34 -20
  87. data/lib/prompt_objects/version.rb +5 -0
  88. data/lib/prompt_objects.rb +33 -8
  89. data/objects/coordinator.md +8 -4
  90. data/templates/arc-agi-1/objects/data_manager.md +2 -2
  91. data/templates/arc-agi-1/objects/observer.md +2 -2
  92. data/templates/arc-agi-1/objects/solver.md +6 -5
  93. data/templates/arc-agi-1/objects/verifier.md +2 -2
  94. data/templates/arc-agi-3/manifest.yml +19 -0
  95. data/templates/arc-agi-3/objects/analyst.md +79 -0
  96. data/templates/arc-agi-3/objects/player.md +76 -0
  97. data/templates/arc-agi-3/objects/strategist.md +97 -0
  98. data/templates/arc-agi-3/primitives/action_log.rb +133 -0
  99. data/templates/arc-agi-3/primitives/arc3_api.rb +223 -0
  100. data/templates/arc-agi-3/primitives/arc3_scorecard.rb +131 -0
  101. data/templates/arc-agi-3/primitives/frame_diff.rb +143 -0
  102. data/templates/arc-agi-3/primitives/frame_info.rb +96 -0
  103. data/templates/arc-agi-3/primitives/render_frame.rb +140 -0
  104. data/templates/basic/objects/basic.md +11 -3
  105. data/templates/chiptune/extras/sonic_pi_boot.rb +220 -0
  106. data/templates/chiptune/manifest.yml +20 -0
  107. data/templates/chiptune/objects/conductor.md +184 -0
  108. data/templates/chiptune/objects/sonic_pi.md +65 -0
  109. data/templates/chiptune/objects/voice_bass.md +44 -0
  110. data/templates/chiptune/objects/voice_lead.md +44 -0
  111. data/templates/chiptune/objects/voice_pad.md +43 -0
  112. data/templates/chiptune/objects/voice_perc.md +45 -0
  113. data/templates/chiptune/primitives/play_note.rb +59 -0
  114. data/templates/chiptune/primitives/play_sequence.rb +55 -0
  115. data/templates/chiptune/primitives/query_sonic_pi.rb +141 -0
  116. data/templates/chiptune/primitives/set_synth.rb +57 -0
  117. data/templates/chiptune/primitives/set_tempo.rb +45 -0
  118. data/templates/chiptune/primitives/stop_voice.rb +46 -0
  119. data/templates/chiptune/primitives/support/osc_bridge.rb +33 -0
  120. data/templates/chiptune/vendor/osc-ruby-1.1.5/LICENSE +19 -0
  121. data/templates/chiptune/vendor/osc-ruby-1.1.5/lib/osc-ruby/address_pattern.rb +58 -0
  122. data/templates/chiptune/vendor/osc-ruby-1.1.5/lib/osc-ruby/broadcast_client.rb +24 -0
  123. data/templates/chiptune/vendor/osc-ruby-1.1.5/lib/osc-ruby/bundle.rb +46 -0
  124. data/templates/chiptune/vendor/osc-ruby-1.1.5/lib/osc-ruby/client.rb +13 -0
  125. data/templates/chiptune/vendor/osc-ruby-1.1.5/lib/osc-ruby/core_ext/time.rb +6 -0
  126. data/templates/chiptune/vendor/osc-ruby-1.1.5/lib/osc-ruby/em_server.rb +63 -0
  127. data/templates/chiptune/vendor/osc-ruby-1.1.5/lib/osc-ruby/message.rb +46 -0
  128. data/templates/chiptune/vendor/osc-ruby-1.1.5/lib/osc-ruby/network_packet.rb +42 -0
  129. data/templates/chiptune/vendor/osc-ruby-1.1.5/lib/osc-ruby/osc_argument.rb +26 -0
  130. data/templates/chiptune/vendor/osc-ruby-1.1.5/lib/osc-ruby/osc_packet.rb +140 -0
  131. data/templates/chiptune/vendor/osc-ruby-1.1.5/lib/osc-ruby/osc_types.rb +53 -0
  132. data/templates/chiptune/vendor/osc-ruby-1.1.5/lib/osc-ruby/server.rb +91 -0
  133. data/templates/chiptune/vendor/osc-ruby-1.1.5/lib/osc-ruby.rb +18 -0
  134. data/templates/developer/objects/code_reviewer.md +1 -1
  135. data/templates/developer/objects/coordinator.md +16 -3
  136. data/templates/developer/objects/debugger.md +3 -3
  137. data/templates/empty/objects/assistant.md +10 -5
  138. data/templates/minimal/objects/assistant.md +10 -5
  139. data/templates/music/extras/sonic_pi_boot.rb +220 -0
  140. data/templates/music/manifest.yml +16 -0
  141. data/templates/music/objects/conductor.md +154 -0
  142. data/templates/music/objects/sonic_pi.md +65 -0
  143. data/templates/music/primitives/play_note.rb +55 -0
  144. data/templates/music/primitives/play_sequence.rb +51 -0
  145. data/templates/music/primitives/query_sonic_pi.rb +139 -0
  146. data/templates/music/primitives/set_synth.rb +53 -0
  147. data/templates/music/primitives/set_tempo.rb +43 -0
  148. data/templates/music/primitives/stop_voice.rb +44 -0
  149. data/templates/music/primitives/support/osc_bridge.rb +33 -0
  150. data/templates/music/vendor/osc-ruby-1.1.5/LICENSE +19 -0
  151. data/templates/music/vendor/osc-ruby-1.1.5/lib/osc-ruby/address_pattern.rb +58 -0
  152. data/templates/music/vendor/osc-ruby-1.1.5/lib/osc-ruby/broadcast_client.rb +24 -0
  153. data/templates/music/vendor/osc-ruby-1.1.5/lib/osc-ruby/bundle.rb +46 -0
  154. data/templates/music/vendor/osc-ruby-1.1.5/lib/osc-ruby/client.rb +13 -0
  155. data/templates/music/vendor/osc-ruby-1.1.5/lib/osc-ruby/core_ext/time.rb +6 -0
  156. data/templates/music/vendor/osc-ruby-1.1.5/lib/osc-ruby/em_server.rb +63 -0
  157. data/templates/music/vendor/osc-ruby-1.1.5/lib/osc-ruby/message.rb +46 -0
  158. data/templates/music/vendor/osc-ruby-1.1.5/lib/osc-ruby/network_packet.rb +42 -0
  159. data/templates/music/vendor/osc-ruby-1.1.5/lib/osc-ruby/osc_argument.rb +26 -0
  160. data/templates/music/vendor/osc-ruby-1.1.5/lib/osc-ruby/osc_packet.rb +140 -0
  161. data/templates/music/vendor/osc-ruby-1.1.5/lib/osc-ruby/osc_types.rb +53 -0
  162. data/templates/music/vendor/osc-ruby-1.1.5/lib/osc-ruby/server.rb +91 -0
  163. data/templates/music/vendor/osc-ruby-1.1.5/lib/osc-ruby.rb +18 -0
  164. data/templates/music-beginner/README.md +100 -0
  165. data/templates/music-beginner/extras/README.md +27 -0
  166. data/templates/music-beginner/extras/sonic_pi_boot.rb +297 -0
  167. data/templates/music-beginner/manifest.yml +25 -0
  168. data/templates/music-beginner/objects/conductor.md +222 -0
  169. data/templates/music-beginner/objects/engine_setup.md +54 -0
  170. data/templates/music-beginner/objects/voice_bass.md +43 -0
  171. data/templates/music-beginner/objects/voice_keys.md +48 -0
  172. data/templates/music-beginner/objects/voice_pad.md +46 -0
  173. data/templates/music-beginner/objects/voice_percussion.md +45 -0
  174. data/templates/music-beginner/objects/voice_texture.md +37 -0
  175. data/templates/music-beginner/primitives/play_pattern.rb +118 -0
  176. data/templates/music-beginner/primitives/query_music_engine.rb +94 -0
  177. data/templates/music-beginner/primitives/set_instrument.rb +47 -0
  178. data/templates/music-beginner/primitives/set_tempo.rb +43 -0
  179. data/templates/music-beginner/primitives/set_voice_level.rb +46 -0
  180. data/templates/music-beginner/primitives/stop_voice.rb +37 -0
  181. data/templates/music-beginner/primitives/support/osc_bridge.rb +25 -0
  182. data/templates/music-beginner/vendor/osc-ruby-1.1.5/LICENSE +19 -0
  183. data/templates/music-beginner/vendor/osc-ruby-1.1.5/lib/osc-ruby/address_pattern.rb +58 -0
  184. data/templates/music-beginner/vendor/osc-ruby-1.1.5/lib/osc-ruby/broadcast_client.rb +24 -0
  185. data/templates/music-beginner/vendor/osc-ruby-1.1.5/lib/osc-ruby/bundle.rb +46 -0
  186. data/templates/music-beginner/vendor/osc-ruby-1.1.5/lib/osc-ruby/client.rb +13 -0
  187. data/templates/music-beginner/vendor/osc-ruby-1.1.5/lib/osc-ruby/core_ext/time.rb +6 -0
  188. data/templates/music-beginner/vendor/osc-ruby-1.1.5/lib/osc-ruby/em_server.rb +63 -0
  189. data/templates/music-beginner/vendor/osc-ruby-1.1.5/lib/osc-ruby/message.rb +46 -0
  190. data/templates/music-beginner/vendor/osc-ruby-1.1.5/lib/osc-ruby/network_packet.rb +42 -0
  191. data/templates/music-beginner/vendor/osc-ruby-1.1.5/lib/osc-ruby/osc_argument.rb +26 -0
  192. data/templates/music-beginner/vendor/osc-ruby-1.1.5/lib/osc-ruby/osc_packet.rb +140 -0
  193. data/templates/music-beginner/vendor/osc-ruby-1.1.5/lib/osc-ruby/osc_types.rb +53 -0
  194. data/templates/music-beginner/vendor/osc-ruby-1.1.5/lib/osc-ruby/server.rb +91 -0
  195. data/templates/music-beginner/vendor/osc-ruby-1.1.5/lib/osc-ruby.rb +18 -0
  196. data/templates/music-pro/extras/AUDITION.md +100 -0
  197. data/templates/music-pro/extras/sonic_pi_boot.rb +546 -0
  198. data/templates/music-pro/manifest.yml +19 -0
  199. data/templates/music-pro/objects/conductor.md +126 -0
  200. data/templates/music-pro/objects/sonic_pi.md +35 -0
  201. data/templates/music-pro/objects/voice_harmony.md +48 -0
  202. data/templates/music-pro/objects/voice_rhythm.md +43 -0
  203. data/templates/music-pro/objects/voice_texture.md +42 -0
  204. data/templates/music-pro/primitives/define_patch.rb +46 -0
  205. data/templates/music-pro/primitives/query_music_engine.rb +69 -0
  206. data/templates/music-pro/primitives/set_live_pattern.rb +70 -0
  207. data/templates/music-pro/primitives/set_tempo.rb +31 -0
  208. data/templates/music-pro/primitives/stop_voice.rb +31 -0
  209. data/templates/music-pro/primitives/support/music_protocol.rb +136 -0
  210. data/templates/music-pro/primitives/support/osc_bridge.rb +33 -0
  211. data/templates/music-pro/primitives/trigger_event.rb +44 -0
  212. data/templates/music-pro/vendor/osc-ruby-1.1.5/LICENSE +19 -0
  213. data/templates/music-pro/vendor/osc-ruby-1.1.5/lib/osc-ruby/address_pattern.rb +58 -0
  214. data/templates/music-pro/vendor/osc-ruby-1.1.5/lib/osc-ruby/broadcast_client.rb +24 -0
  215. data/templates/music-pro/vendor/osc-ruby-1.1.5/lib/osc-ruby/bundle.rb +46 -0
  216. data/templates/music-pro/vendor/osc-ruby-1.1.5/lib/osc-ruby/client.rb +13 -0
  217. data/templates/music-pro/vendor/osc-ruby-1.1.5/lib/osc-ruby/core_ext/time.rb +6 -0
  218. data/templates/music-pro/vendor/osc-ruby-1.1.5/lib/osc-ruby/em_server.rb +63 -0
  219. data/templates/music-pro/vendor/osc-ruby-1.1.5/lib/osc-ruby/message.rb +46 -0
  220. data/templates/music-pro/vendor/osc-ruby-1.1.5/lib/osc-ruby/network_packet.rb +42 -0
  221. data/templates/music-pro/vendor/osc-ruby-1.1.5/lib/osc-ruby/osc_argument.rb +26 -0
  222. data/templates/music-pro/vendor/osc-ruby-1.1.5/lib/osc-ruby/osc_packet.rb +140 -0
  223. data/templates/music-pro/vendor/osc-ruby-1.1.5/lib/osc-ruby/osc_types.rb +53 -0
  224. data/templates/music-pro/vendor/osc-ruby-1.1.5/lib/osc-ruby/server.rb +91 -0
  225. data/templates/music-pro/vendor/osc-ruby-1.1.5/lib/osc-ruby.rb +18 -0
  226. data/templates/writer/objects/coordinator.md +14 -3
  227. data/templates/writer/objects/editor.md +1 -1
  228. data/templates/writer/objects/researcher.md +2 -2
  229. data/tools/thread-explorer.html +1 -1
  230. metadata +172 -65
  231. data/CLAUDE.md +0 -177
  232. data/Gemfile +0 -10
  233. data/Rakefile +0 -27
  234. data/frontend/.gitignore +0 -3
  235. data/frontend/index.html +0 -17
  236. data/frontend/package-lock.json +0 -4540
  237. data/frontend/package.json +0 -36
  238. data/frontend/postcss.config.js +0 -6
  239. data/frontend/src/App.tsx +0 -104
  240. data/frontend/src/canvas/CanvasView.tsx +0 -113
  241. data/frontend/src/canvas/ForceLayout.ts +0 -115
  242. data/frontend/src/canvas/SceneManager.ts +0 -587
  243. data/frontend/src/canvas/canvasStore.ts +0 -47
  244. data/frontend/src/canvas/constants.ts +0 -95
  245. data/frontend/src/canvas/controls/CameraControls.ts +0 -98
  246. data/frontend/src/canvas/edges/MessageArc.ts +0 -149
  247. data/frontend/src/canvas/inspector/InspectorPanel.tsx +0 -31
  248. data/frontend/src/canvas/inspector/POInspector.tsx +0 -262
  249. data/frontend/src/canvas/inspector/ToolCallInspector.tsx +0 -67
  250. data/frontend/src/canvas/nodes/PONode.ts +0 -249
  251. data/frontend/src/canvas/nodes/ToolCallNode.ts +0 -116
  252. data/frontend/src/canvas/types.ts +0 -24
  253. data/frontend/src/components/ContextMenu.tsx +0 -68
  254. data/frontend/src/components/EnvDataPane.tsx +0 -69
  255. data/frontend/src/components/Inspector.tsx +0 -263
  256. data/frontend/src/components/MarkdownMessage.tsx +0 -155
  257. data/frontend/src/components/MethodList.tsx +0 -90
  258. data/frontend/src/components/ModelSelector.tsx +0 -115
  259. data/frontend/src/components/NotificationPanel.tsx +0 -130
  260. data/frontend/src/components/ObjectList.tsx +0 -78
  261. data/frontend/src/components/PaneSlot.tsx +0 -30
  262. data/frontend/src/components/SourcePane.tsx +0 -202
  263. data/frontend/src/components/SystemBar.tsx +0 -74
  264. data/frontend/src/components/Transcript.tsx +0 -76
  265. data/frontend/src/components/UsagePanel.tsx +0 -105
  266. data/frontend/src/components/Workspace.tsx +0 -260
  267. data/frontend/src/components/index.ts +0 -12
  268. data/frontend/src/hooks/useResize.ts +0 -55
  269. data/frontend/src/hooks/useWebSocket.ts +0 -559
  270. data/frontend/src/index.css +0 -102
  271. data/frontend/src/main.tsx +0 -10
  272. data/frontend/src/store/index.ts +0 -311
  273. data/frontend/src/types/index.ts +0 -176
  274. data/frontend/tailwind.config.js +0 -44
  275. data/frontend/tsconfig.json +0 -30
  276. data/frontend/vite.config.ts +0 -29
  277. data/lib/prompt_objects/server/public/assets/index-DEPawnfZ.js +0 -4345
  278. data/lib/prompt_objects/server/public/assets/index-oMrRce1m.css +0 -1
  279. data/lib/prompt_objects/universal/add_primitive.rb +0 -118
  280. data/lib/prompt_objects/universal/ask_human.rb +0 -109
  281. data/lib/prompt_objects/universal/create_primitive.rb +0 -174
  282. data/lib/prompt_objects/universal/list_primitives.rb +0 -145
  283. data/lib/prompt_objects/universal/think.rb +0 -41
  284. data/lib/prompt_objects/universal/update_env_data.rb +0 -88
  285. data/prompt_objects.gemspec +0 -50
  286. data/templates/basic/.gitignore +0 -2
  287. data/templates/developer/.gitignore +0 -5
  288. data/templates/empty/.gitignore +0 -5
  289. data/templates/empty/objects/.gitkeep +0 -0
  290. data/templates/minimal/.gitignore +0 -5
  291. data/templates/writer/.gitignore +0 -5
@@ -1,17 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PromptObjects
4
- # A Prompt Object is a capability backed by an LLM.
5
- # It interprets messages semantically using its markdown "soul" as the system prompt.
4
+ # An immutable-at-execution-time LLM capability definition. Conversation
5
+ # state belongs to runtime threads, runs, messages, and events; this object
6
+ # only owns the editable definition loaded from markdown.
6
7
  class PromptObject < Capability
7
- attr_reader :config, :body, :history, :session_id, :path
8
- attr_accessor :on_history_updated # Callback for real-time updates during receive loop
9
-
10
- # @param config [Hash] Parsed frontmatter (name, description, capabilities)
11
- # @param body [String] Markdown body (the "soul" - becomes system prompt)
12
- # @param env [Environment] Reference to the environment
13
- # @param llm [LLM::OpenAIAdapter] LLM adapter for making calls
14
- # @param path [String, nil] Path to the source .md file (for persistence)
8
+ execution_policy parallel_safe: true, side_effect: :write, resource_keys: ->(_arguments, _context, _capability) { [] }
9
+
10
+ attr_reader :config, :body, :path
11
+
15
12
  def initialize(config:, body:, env:, llm:, path: nil)
16
13
  super()
17
14
  @config = config
@@ -19,17 +16,6 @@ module PromptObjects
19
16
  @env = env
20
17
  @llm = llm
21
18
  @path = path
22
- @history = []
23
- @session_id = nil
24
-
25
- # Load existing session if session store is available
26
- load_or_create_session if session_store
27
- end
28
-
29
- # Get the session store from the environment.
30
- # @return [Session::Store, nil]
31
- def session_store
32
- @env.session_store
33
19
  end
34
20
 
35
21
  def name
@@ -40,7 +26,24 @@ module PromptObjects
40
26
  @config["description"] || "A prompt object"
41
27
  end
42
28
 
43
- # Prompt objects accept natural language messages.
29
+ def autonomous?
30
+ !!@config["autonomous"]
31
+ end
32
+
33
+ def autonomous_config
34
+ return nil unless autonomous?
35
+
36
+ auto = @config["autonomous"]
37
+ auto = {} unless auto.is_a?(Hash)
38
+ {
39
+ "interval" => auto["interval"] || 30,
40
+ "initial_delay" => auto["initial_delay"] || auto["interval"] || 30,
41
+ "message" => auto["message"] || "Check your environment and act.",
42
+ "start_on_boot" => auto.fetch("start_on_boot", true),
43
+ "wait_for_human" => auto.fetch("wait_for_human", false)
44
+ }
45
+ end
46
+
44
47
  def parameters
45
48
  {
46
49
  type: "object",
@@ -54,271 +57,138 @@ module PromptObjects
54
57
  }
55
58
  end
56
59
 
57
- # Handle an incoming message by running the LLM conversation loop.
58
- # @param message [String, Hash] The incoming message
59
- # @param context [Hash] Execution context
60
- # @return [String] The response
61
- def receive(message, context:)
62
- # Normalize message to string
63
- content = normalize_message(message)
64
-
65
- # Track who sent this message - another PO or a human?
66
- sender = context.calling_po
67
- from = (sender && sender != name) ? sender : "human"
68
-
69
- user_msg = { role: :user, content: content, from: from }
70
- @history << user_msg
71
- persist_message(user_msg)
72
- @state = :working
73
-
74
- # Conversation loop - keep going until LLM responds without tool calls
75
- loop do
76
- response = @llm.chat(
77
- system: build_system_prompt,
78
- messages: @history,
79
- tools: available_tool_descriptors
80
- )
81
-
82
- if response.tool_calls?
83
- # Execute tools and continue the loop
84
- results = execute_tool_calls(response.tool_calls, context)
85
- assistant_msg = {
86
- role: :assistant,
87
- # Don't include content when there are tool calls - force LLM to
88
- # wait for tool results before generating a response. This prevents
89
- # the model from "hedging" by generating both a response AND a tool call.
90
- content: nil,
91
- tool_calls: response.tool_calls,
92
- usage: response.usage
93
- }
94
- @history << assistant_msg
95
- persist_message(assistant_msg)
96
-
97
- tool_msg = { role: :tool, results: results }
98
- @history << tool_msg
99
- persist_message(tool_msg)
100
-
101
- # Notify callback for real-time UI updates (tool calls as they happen)
102
- notify_history_updated
103
- else
104
- # No tool calls - we have our final response
105
- assistant_msg = { role: :assistant, content: response.content, usage: response.usage }
106
- @history << assistant_msg
107
- persist_message(assistant_msg)
108
- @state = :idle
109
- return response.content
110
- end
60
+ # The raw markdown source for definition editing. File watcher reloads
61
+ # replace instances, while direct edits update this memo in place.
62
+ def source
63
+ unless @source_loaded
64
+ @source_loaded = true
65
+ @source = (@path && File.exist?(@path)) ? File.read(@path, encoding: "UTF-8") : nil
111
66
  end
67
+ @source
112
68
  end
113
69
 
114
- # --- Session Management ---
115
-
116
- # List all sessions for this PO.
117
- # @return [Array<Hash>] Session data
118
- def list_sessions
119
- return [] unless session_store
120
-
121
- session_store.list_sessions(po_name: name)
122
- end
123
-
124
- # Switch to a different session.
125
- # @param session_id [String] Session ID to switch to
126
- # @return [Boolean] True if switch was successful
127
- def switch_session(session_id)
128
- return false unless session_store
129
-
130
- session = session_store.get_session(session_id)
131
- return false unless session && session[:po_name] == name
70
+ def update_source(raw)
71
+ return false unless @path
132
72
 
133
- @session_id = session_id
134
- reload_history_from_session
73
+ data = Loader.parse(raw, path: @path)
74
+ @env.suppress_next_reload(@path) if @env.respond_to?(:suppress_next_reload)
75
+ File.write(@path, raw, encoding: "UTF-8")
76
+ @config = data[:config]
77
+ @body = data[:body]
78
+ @source = raw
79
+ @source_loaded = true
135
80
  true
81
+ rescue => e
82
+ puts "Error updating source for PO #{name}: #{e.message}" if ENV["DEBUG"]
83
+ false
136
84
  end
137
85
 
138
- # Create a new session and switch to it.
139
- # @param name [String, nil] Optional session name
140
- # @return [String] New session ID
141
- def new_session(name: nil)
142
- return nil unless session_store
143
-
144
- @session_id = session_store.create_session(po_name: self.name, name: name)
145
- @history = []
146
- @session_id
147
- end
86
+ def relocate(new_path)
87
+ return false unless @path && File.exist?(@path)
88
+ return false if new_path == @path
89
+ return false if File.exist?(new_path)
148
90
 
149
- # Clear the current session's history.
150
- def clear_history
151
- @history = []
152
- session_store&.clear_messages(@session_id) if @session_id
91
+ File.rename(@path, new_path)
92
+ @path = new_path
93
+ true
94
+ rescue => e
95
+ puts "Error relocating PO #{name}: #{e.message}" if ENV["DEBUG"]
96
+ false
153
97
  end
154
98
 
155
- # --- File Persistence ---
156
-
157
- # Save the current config and body back to the source file.
158
- # This persists any runtime changes (like added capabilities) to disk.
159
- # @return [Boolean] True if saved successfully, false if no path or error
160
99
  def save
161
100
  return false unless @path
162
101
 
163
- # Build YAML frontmatter with proper formatting
164
- yaml_content = @config.to_yaml
165
-
166
- # Combine frontmatter and body
167
- content = "#{yaml_content}---\n\n#{@body}\n"
168
-
102
+ content = "#{save_frontmatter_yaml}---\n\n#{@body}\n"
103
+ @env.suppress_next_reload(@path) if @env.respond_to?(:suppress_next_reload)
169
104
  File.write(@path, content, encoding: "UTF-8")
105
+ @source = content
106
+ @source_loaded = true
170
107
  true
171
108
  rescue => e
172
109
  puts "Error saving PO #{name} to #{@path}: #{e.message}" if ENV["DEBUG"]
173
110
  false
174
111
  end
175
112
 
176
- # --- Thread/Delegation Support ---
177
-
178
- # Create a delegation thread for handling a call from another PO.
179
- # @param parent_po [String] Name of the PO that initiated the call
180
- # @param parent_session_id [String] Session ID in the parent PO
181
- # @param parent_message_id [Integer, nil] Message ID that triggered the delegation
182
- # @return [String, nil] New thread ID or nil if no session store
183
- def create_delegation_thread(parent_po:, parent_session_id:, parent_message_id: nil)
184
- return nil unless session_store
113
+ def save_frontmatter_yaml
114
+ frontmatter = @config.dup
115
+ capabilities = frontmatter.delete("capabilities") || []
185
116
 
186
- session_store.create_thread(
187
- po_name: name,
188
- parent_po: parent_po,
189
- parent_session_id: parent_session_id,
190
- parent_message_id: parent_message_id,
191
- thread_type: "delegation",
192
- source: "delegation"
193
- )
194
- end
195
-
196
- # Execute a message in a specific thread, then restore the original session.
197
- # @param message [String, Hash] The message to process
198
- # @param context [Context] Execution context
199
- # @param thread_id [String] Thread ID to execute in
200
- # @return [String] The response
201
- def receive_in_thread(message, context:, thread_id:)
202
- original_session = @session_id
203
- original_history = @history.dup
204
-
205
- # Switch to delegation thread
206
- @session_id = thread_id
207
- @history = []
208
- reload_history_from_session
209
-
210
- begin
211
- result = receive(message, context: context)
212
- result
213
- ensure
214
- # Restore original session
215
- @session_id = original_session
216
- @history = original_history
117
+ if capabilities.any?
118
+ registry = @env.respond_to?(:registry) ? @env.registry : nil
119
+ delegates, tools = capabilities.partition { |capability_name| registry&.get(capability_name).is_a?(PromptObject) }
120
+ frontmatter["delegates_to"] = delegates if delegates.any?
121
+ frontmatter["tools"] = tools if tools.any?
217
122
  end
218
- end
219
-
220
- # Create a new root thread and switch to it.
221
- # @param name [String, nil] Optional thread name
222
- # @return [String] New thread ID
223
- def new_thread(name: nil)
224
- return nil unless session_store
225
123
 
226
- @session_id = session_store.create_thread(
227
- po_name: self.name,
228
- name: name,
229
- thread_type: "root"
230
- )
231
- @history = []
232
- @session_id
124
+ frontmatter.to_yaml
233
125
  end
234
126
 
235
- # --- Serialization ---
236
- # Canonical methods for converting PO state to hashes for broadcasting,
237
- # REST API responses, and MCP tool output. All consumers should use these
238
- # to ensure consistent capability format across WebSocket, REST, and MCP.
239
-
240
- # Full state hash for WebSocket/broadcast consumers.
241
- # Matches the frontend's PromptObject TypeScript type.
242
- # @param registry [Registry, nil] Registry for resolving capability descriptions
243
- # @return [Hash]
127
+ # Definition state for WebSocket and REST consumers. Runtime activity is
128
+ # delivered separately from authoritative repository snapshots/events.
244
129
  def to_state_hash(registry: nil)
245
130
  {
246
- status: (@state || :idle).to_s,
131
+ status: "idle",
247
132
  description: description,
248
133
  capabilities: resolve_capabilities(registry: registry),
249
134
  universal_capabilities: resolve_universal_capabilities(registry: registry),
250
- current_session: serialize_current_session,
251
- sessions: list_sessions.map { |s| self.class.serialize_session(s) },
252
135
  prompt: body,
253
- config: config
136
+ config: config,
137
+ source: source
254
138
  }
255
139
  end
256
140
 
257
- # Summary hash for list endpoints (REST API, MCP list tools).
258
- # Lightweight: no prompt, no session messages, no universal capabilities.
259
- # @param registry [Registry, nil] Registry for resolving capability descriptions
260
- # @return [Hash]
261
141
  def to_summary_hash(registry: nil)
262
142
  {
263
143
  name: name,
264
144
  description: description,
265
- status: (@state || :idle).to_s,
266
- capabilities: resolve_capabilities(registry: registry),
267
- session_count: list_sessions.size
145
+ status: "idle",
146
+ capabilities: resolve_capabilities(registry: registry)
268
147
  }
269
148
  end
270
149
 
271
- # Detailed inspection hash for single-PO endpoints (REST GET, MCP inspect).
272
- # Everything in summary plus prompt, config, sessions, and universals.
273
- # @param registry [Registry, nil] Registry for resolving capability descriptions
274
- # @return [Hash]
275
150
  def to_inspect_hash(registry: nil)
276
151
  {
277
152
  name: name,
278
153
  description: description,
279
- status: (@state || :idle).to_s,
154
+ status: "idle",
280
155
  capabilities: resolve_capabilities(registry: registry),
281
156
  universal_capabilities: resolve_universal_capabilities(registry: registry),
282
157
  prompt: body,
283
158
  config: config,
284
- session_id: session_id,
285
- sessions: list_sessions.map { |s| self.class.serialize_session(s) },
286
- history_length: history.length
159
+ source: source
287
160
  }
288
161
  end
289
162
 
290
- # Serialize a message (in-memory or from DB) to a JSON-ready hash.
291
- # Handles both ToolCall objects and plain Hashes (from SQLite).
292
- # @param msg [Hash] The message to serialize
293
- # @return [Hash]
294
- def self.serialize_message(msg)
295
- case msg[:role]
163
+ # Transitional wire serializers for repository records. They do not read or
164
+ # mutate PromptObject state and can be removed with the older endpoint shape.
165
+ def self.serialize_message(message)
166
+ case message[:role]
296
167
  when :user
297
- from = msg[:from] || msg[:from_po]
298
- { role: "user", content: msg[:content], from: from }
168
+ { role: "user", content: message[:content], from: message[:from] || message[:from_po] }
299
169
  when :assistant
300
- hash = { role: "assistant", content: msg[:content] }
301
- if msg[:tool_calls]
302
- hash[:tool_calls] = msg[:tool_calls].map do |tc|
303
- if tc.respond_to?(:id)
304
- { id: tc.id, name: tc.name, arguments: tc.arguments }
170
+ hash = { role: "assistant", content: message[:content] }
171
+ if message[:tool_calls]
172
+ hash[:tool_calls] = message[:tool_calls].map do |tool_call|
173
+ if tool_call.respond_to?(:id)
174
+ { id: tool_call.id, name: tool_call.name, arguments: tool_call.arguments }
305
175
  else
306
- { id: tc[:id] || tc["id"], name: tc[:name] || tc["name"], arguments: tc[:arguments] || tc["arguments"] || {} }
176
+ {
177
+ id: tool_call[:id] || tool_call["id"],
178
+ name: tool_call[:name] || tool_call["name"],
179
+ arguments: tool_call[:arguments] || tool_call["arguments"] || {}
180
+ }
307
181
  end
308
182
  end
309
183
  end
310
184
  hash
311
185
  when :tool
312
- results = msg[:results] || msg[:tool_results]
313
- { role: "tool", results: results }
186
+ { role: "tool", results: message[:results] || message[:tool_results] }
314
187
  else
315
- { role: msg[:role].to_s, content: msg[:content] }
188
+ { role: message[:role].to_s, content: message[:content] }
316
189
  end
317
190
  end
318
191
 
319
- # Serialize a session record to a JSON-ready hash with thread fields.
320
- # @param session [Hash] Session record from session store
321
- # @return [Hash]
322
192
  def self.serialize_session(session)
323
193
  {
324
194
  id: session[:id],
@@ -331,66 +201,78 @@ module PromptObjects
331
201
  }
332
202
  end
333
203
 
204
+ # Capture the exact definition used by a run. Hot reload or definition
205
+ # editing cannot alter this deeply frozen snapshot after enqueue.
206
+ def execution_definition(provider: nil, model: nil, capability_policy_version: "1")
207
+ snapshot_config = deep_freeze(Marshal.load(Marshal.dump(@config)))
208
+ version_payload = JSON.generate(canonicalize({ "prompt" => @body, "config" => snapshot_config }))
209
+ Domain::PromptObjectDefinition.new(
210
+ po_name: name,
211
+ version: Digest::SHA256.hexdigest(version_payload),
212
+ prompt: @body.dup.freeze,
213
+ config: snapshot_config,
214
+ provider: provider,
215
+ model: model,
216
+ capability_policy_version: capability_policy_version
217
+ )
218
+ end
219
+
220
+ def system_prompt_for(definition)
221
+ build_system_prompt_from(
222
+ prompt: definition.prompt,
223
+ config: definition.config,
224
+ object_name: definition.po_name
225
+ )
226
+ end
227
+
228
+ def tool_descriptors_for(definition)
229
+ descriptors_for_config(definition.config)
230
+ end
231
+
334
232
  private
335
233
 
336
- # Build capability info objects for this PO's declared capabilities.
337
234
  def resolve_capabilities(registry: nil)
338
235
  declared = @config["capabilities"] || []
339
- return declared.map { |n| { name: n, description: n } } unless registry
340
-
341
- declared.map do |cap_name|
342
- cap = registry.get(cap_name)
343
- { name: cap_name, description: cap&.description || "Capability not found", parameters: cap&.parameters }
236
+ return declared.map { |capability_name| { name: capability_name, description: capability_name, kind: "tool" } } unless registry
237
+
238
+ declared.map do |capability_name|
239
+ capability = registry.get(capability_name)
240
+ kind = capability.is_a?(PromptObject) ? "delegate" : "tool"
241
+ {
242
+ name: capability_name,
243
+ description: capability&.description || "Capability not found",
244
+ parameters: capability&.parameters,
245
+ kind: kind
246
+ }
344
247
  end
345
248
  end
346
249
 
347
- # Build capability info objects for universal capabilities.
348
250
  def resolve_universal_capabilities(registry: nil)
349
251
  return [] unless registry
350
252
 
351
- UNIVERSAL_CAPABILITIES.map do |cap_name|
352
- cap = registry.get(cap_name)
353
- { name: cap_name, description: cap&.description || "Universal capability", parameters: cap&.parameters }
253
+ UNIVERSAL_CAPABILITIES.map do |capability_name|
254
+ capability = registry.get(capability_name)
255
+ {
256
+ name: capability_name,
257
+ description: capability&.description || "Universal capability",
258
+ parameters: capability&.parameters
259
+ }
354
260
  end
355
261
  end
356
262
 
357
- # Serialize current session with messages for real-time state.
358
- def serialize_current_session
359
- return nil unless session_id
360
- { id: session_id, messages: history.map { |m| self.class.serialize_message(m) } }
361
- end
362
-
363
- def normalize_message(message)
364
- case message
365
- when String
366
- message
367
- when Hash
368
- message[:message] || message["message"] || message.to_s
369
- else
370
- message.to_s
371
- end
372
- end
373
-
374
- def available_tool_descriptors
375
- # Get declared capabilities from config
376
- declared = @config["capabilities"] || []
377
-
378
- # Add universal capabilities (available to all POs)
379
- all_caps = declared + UNIVERSAL_CAPABILITIES
380
-
381
- all_caps.filter_map do |cap_name|
382
- cap = @env.registry&.get(cap_name)
383
- cap&.descriptor
263
+ def descriptors_for_config(config)
264
+ declared = config["capabilities"] || config[:capabilities] || []
265
+ (declared + UNIVERSAL_CAPABILITIES).filter_map do |capability_name|
266
+ @env.registry&.get(capability_name)&.descriptor
384
267
  end
385
268
  end
386
269
 
387
- def build_system_prompt
388
- declared_caps = @config["capabilities"] || []
389
-
270
+ def build_system_prompt_from(prompt:, config:, object_name:)
271
+ declared_caps = config["capabilities"] || config[:capabilities] || []
390
272
  context_block = <<~CONTEXT
391
273
  ## System Context
392
274
 
393
- You are a prompt object named "#{name}" running in a PromptObjects environment.
275
+ You are a prompt object named "#{object_name}" running in a PromptObjects environment.
394
276
 
395
277
  ### What is a Prompt Object?
396
278
  You are an autonomous entity defined by a markdown file. You have an identity (your prompt),
@@ -401,277 +283,47 @@ module PromptObjects
401
283
  You may receive messages from:
402
284
  - **A human** interacting with you directly through the UI
403
285
  - **Another prompt object** that has delegated a task to you as part of a larger workflow
286
+ - **Your own interface** — a person clicking a control in an artifact you rendered
287
+ (arrives from `artifact:<title>`)
288
+ - **A timer**, if you are configured as autonomous (arrives from `timer:#{object_name}`)
404
289
 
405
290
  When called by another PO, you'll see a delegation context block in the message with details
406
291
  about who called you and why. You can also check shared environment data (via `list_env_data`)
407
292
  for context that other POs in the same workflow have stored.
408
293
 
409
294
  ### Your capabilities
410
- When using tools that target a PO (like add_capability), you can use "self" or "#{name}" to target yourself.
295
+ When using tools that target a PO (like add_capability), you can use "self" or "#{object_name}" to target yourself.
411
296
  - Declared capabilities: #{declared_caps.empty? ? '(none)' : declared_caps.join(', ')}
412
297
  - Universal capabilities (always available): #{UNIVERSAL_CAPABILITIES.join(', ')}
413
298
 
414
- You can create new tools (`create_primitive`) and new prompt objects (`create_capability`) at runtime if needed. Use `list_capabilities` to see everything available.
299
+ You can create new capabilities at runtime with `create_capability` use `type: "primitive"` for a deterministic Ruby tool or `type: "prompt_object"` for a new specialist PO. Use `list_capabilities` to see everything available.
300
+
301
+ ### Your interface (interactive artifacts)
302
+ Besides replying in text, you can build a live UI with `render_artifact` — it renders on the
303
+ human's screen as your station. Artifacts are BIDIRECTIONAL: inside the artifact,
304
+ `PromptObjects.send("...")` sends a natural-language message back to you. Wire it to buttons,
305
+ forms, and controls so people can click instead of type — e.g.
306
+ `<button onClick={() => PromptObjects.send('approve invoice 3')}>Approve</button>`.
307
+ When an `artifact:<title>` message arrives, act on it, then update the UI by re-rendering with
308
+ the same title (replace mode). A re-render resets the artifact's local JS state, so render the
309
+ current state into the HTML each time. For anything with ongoing state — dashboards, controls,
310
+ forms, games — prefer offering an interface over walls of text.
415
311
  CONTEXT
416
312
 
417
- "#{@body}\n\n#{context_block}"
418
- end
419
-
420
- # Build a delegation preamble for a PO-to-PO call.
421
- # @param target_po [PromptObject] The PO being called
422
- # @param delegation_thread [String, nil] The delegation thread ID
423
- # @param context [Context] Execution context
424
- # @return [String, nil] Preamble text or nil if caller isn't a PO
425
- def build_delegation_preamble(target_po, delegation_thread, context)
426
- return nil unless context.calling_po
427
-
428
- caller_po = context.env.registry.get(context.calling_po)
429
- return nil unless caller_po.is_a?(PromptObject)
430
-
431
- parts = []
432
- parts << "---"
433
- parts << "[Delegation Context]"
434
- parts << "Called by: #{caller_po.name}"
435
- parts << "#{caller_po.name} is: \"#{caller_po.description}\""
436
-
437
- chain = build_delegation_chain(delegation_thread)
438
- parts << "Delegation chain: #{chain}" if chain
439
-
440
- if delegation_thread && env_data_available?(delegation_thread)
441
- parts << "Shared environment data is available — call list_env_data() to see what context has been stored."
442
- end
443
-
444
- parts << "---"
445
- parts.join("\n")
446
- end
447
-
448
- # Build a human-readable delegation chain from thread lineage.
449
- # @param delegation_thread [String, nil] The delegation thread ID
450
- # @return [String, nil] Chain like "human → coordinator → solver → you (observer)"
451
- def build_delegation_chain(delegation_thread)
452
- return nil unless delegation_thread && session_store
453
-
454
- lineage = session_store.get_thread_lineage(delegation_thread)
455
- return nil if lineage.empty?
456
-
457
- chain = ["human"]
458
- lineage[0..-2].each do |session|
459
- chain << session[:po_name] if session[:po_name]
460
- end
461
- chain << "you (#{lineage.last[:po_name]})"
462
-
463
- chain.join(" → ")
464
- end
465
-
466
- # Check if any shared environment data exists for a delegation thread.
467
- # @param delegation_thread [String] The delegation thread ID
468
- # @return [Boolean]
469
- def env_data_available?(delegation_thread)
470
- return false unless session_store
471
-
472
- root_thread = session_store.resolve_root_thread(delegation_thread)
473
- entries = session_store.list_env_data(root_thread_id: root_thread)
474
- !entries.empty?
475
- end
476
-
477
- # Build enriched arguments with delegation preamble prepended to the message.
478
- # Returns a NEW hash — does not mutate the original arguments.
479
- # @param target_po [PromptObject] The PO being called
480
- # @param arguments [Hash, String] Original tool call arguments
481
- # @param delegation_thread [String, nil] The delegation thread ID
482
- # @param context [Context] Execution context
483
- # @return [Hash, String] Enriched arguments with preamble, or original if no preamble
484
- def enrich_delegation_message(target_po, arguments, delegation_thread, context)
485
- preamble = build_delegation_preamble(target_po, delegation_thread, context)
486
- return arguments unless preamble
487
-
488
- original_message = normalize_message(arguments)
489
- enriched_message = "#{preamble}\n\n#{original_message}"
490
-
491
- if arguments.is_a?(Hash)
492
- # Create a new hash with the enriched message
493
- key = arguments.key?(:message) ? :message : "message"
494
- arguments.merge(key => enriched_message)
495
- else
496
- enriched_message
497
- end
498
- end
499
-
500
- def execute_tool_calls(tool_calls, context)
501
- # Track the caller for nested calls
502
- previous_capability = context.current_capability
503
- previous_calling_po = context.calling_po
504
-
505
- tool_calls.map do |tc|
506
- capability = @env.registry&.get(tc.name)
507
-
508
- # Guard: only execute tools the PO is allowed to use (declared + universal).
509
- # Without this, the LLM can hallucinate calls to tools it shouldn't have access to.
510
- allowed = (@config["capabilities"] || []) + UNIVERSAL_CAPABILITIES
511
- unless allowed.include?(tc.name)
512
- next { tool_call_id: tc.id, name: tc.name, content: "Capability '#{tc.name}' is not available. Your declared capabilities are: #{(@config["capabilities"] || []).join(', ')}. Use add_capability to add it first, or use list_primitives / list_capabilities to discover what's available." }
513
- end
514
-
515
- if capability
516
- # Log the outgoing message
517
- @env.bus.publish(from: name, to: tc.name, message: tc.arguments)
518
-
519
- # Set context for the tool call
520
- # calling_po tracks which PO is making the call (for "self" resolution)
521
- context.calling_po = name
522
- context.current_capability = tc.name
523
-
524
- result = if capability.is_a?(PromptObject)
525
- # PO-to-PO call: create isolated delegation thread
526
- execute_po_delegation(capability, tc, context)
527
- else
528
- # Primitive call: execute directly
529
- capability.receive(tc.arguments, context: context)
530
- end
531
-
532
- # Restore context
533
- context.current_capability = previous_capability
534
- context.calling_po = previous_calling_po
535
-
536
- # Log the response
537
- @env.bus.publish(from: tc.name, to: name, message: result)
538
-
539
- { tool_call_id: tc.id, name: tc.name, content: result }
540
- else
541
- { tool_call_id: tc.id, name: tc.name, content: "Unknown capability: #{tc.name}" }
542
- end
543
- end
544
- end
545
-
546
- # Execute a delegation to another PO in an isolated thread.
547
- # @param target_po [PromptObject] The PO to delegate to
548
- # @param tool_call [ToolCall] The tool call details
549
- # @param context [Context] Execution context
550
- # @return [String] The response
551
- def execute_po_delegation(target_po, tool_call, context)
552
- # Create a delegation thread in the target PO
553
- delegation_thread = target_po.create_delegation_thread(
554
- parent_po: name,
555
- parent_session_id: @session_id,
556
- parent_message_id: get_last_message_id
557
- )
558
-
559
- # Enrich the message with delegation context (must happen after thread creation
560
- # so build_delegation_chain can walk the lineage)
561
- enriched_args = enrich_delegation_message(target_po, tool_call.arguments, delegation_thread, context)
562
-
563
- # Notify delegation start so WebSocket clients see the target PO activate
564
- @env.notify_delegation(:started, {
565
- target: target_po.name,
566
- caller: name,
567
- thread_id: delegation_thread,
568
- tool_call_id: tool_call.id
569
- })
570
-
571
- begin
572
- if delegation_thread
573
- # Execute in isolated thread
574
- target_po.receive_in_thread(enriched_args, context: context, thread_id: delegation_thread)
575
- else
576
- # Fallback: execute in target's current session (no session store)
577
- target_po.receive(enriched_args, context: context)
578
- end
579
- ensure
580
- # Notify delegation complete — target PO is done
581
- @env.notify_delegation(:completed, {
582
- target: target_po.name,
583
- caller: name,
584
- thread_id: delegation_thread,
585
- tool_call_id: tool_call.id
586
- })
587
- end
313
+ "#{prompt}\n\n#{context_block}"
588
314
  end
589
315
 
590
- # Get the ID of the last message in the current session.
591
- # @return [Integer, nil]
592
- def get_last_message_id
593
- return nil unless session_store && @session_id
594
-
595
- messages = session_store.get_messages(@session_id)
596
- messages.last&.dig(:id)
597
- end
598
-
599
- # Notify the history updated callback if registered.
600
- # Used for real-time UI updates during the receive loop.
601
- def notify_history_updated
602
- @on_history_updated&.call(self, @session_id, @history)
603
- end
604
-
605
- # --- Session Persistence Helpers ---
606
-
607
- # Load existing session or create a new one.
608
- def load_or_create_session
609
- session = session_store.get_or_create_session(po_name: name)
610
- @session_id = session[:id]
611
- reload_history_from_session
612
- end
613
-
614
- # Reload history from the current session.
615
- def reload_history_from_session
616
- return unless session_store && @session_id
617
-
618
- messages = session_store.get_messages(@session_id)
619
- @history = messages.map { |msg| convert_db_message_to_history(msg) }
316
+ def deep_freeze(value)
317
+ value.each { |key, nested| deep_freeze(key); deep_freeze(nested) } if value.is_a?(Hash)
318
+ value.each { |nested| deep_freeze(nested) } if value.is_a?(Array)
319
+ value.freeze
620
320
  end
621
321
 
622
- # Persist a message to the session store.
623
- def persist_message(msg)
624
- return unless session_store && @session_id
322
+ def canonicalize(value)
323
+ return value.keys.sort_by(&:to_s).to_h { |key| [key.to_s, canonicalize(value[key])] } if value.is_a?(Hash)
324
+ return value.map { |nested| canonicalize(nested) } if value.is_a?(Array)
625
325
 
626
- case msg[:role]
627
- when :user
628
- session_store.add_message(
629
- session_id: @session_id,
630
- role: :user,
631
- content: msg[:content],
632
- from_po: msg[:from]
633
- )
634
- when :assistant
635
- # Serialize tool_calls if present
636
- tool_calls_data = msg[:tool_calls]&.map do |tc|
637
- { id: tc.id, name: tc.name, arguments: tc.arguments }
638
- end
639
-
640
- session_store.add_message(
641
- session_id: @session_id,
642
- role: :assistant,
643
- content: msg[:content],
644
- tool_calls: tool_calls_data,
645
- usage: msg[:usage]
646
- )
647
- when :tool
648
- session_store.add_message(
649
- session_id: @session_id,
650
- role: :tool,
651
- tool_results: msg[:results]
652
- )
653
- end
654
- end
655
-
656
- # Convert a database message row to history format.
657
- def convert_db_message_to_history(db_msg)
658
- case db_msg[:role]
659
- when :user
660
- { role: :user, content: db_msg[:content], from: db_msg[:from_po] || "human" }
661
- when :assistant
662
- msg = { role: :assistant, content: db_msg[:content] }
663
- if db_msg[:tool_calls]
664
- # Reconstruct tool call objects from Hashes
665
- msg[:tool_calls] = db_msg[:tool_calls].map do |tc|
666
- LLM::ToolCall.from_hash(tc)
667
- end
668
- end
669
- msg
670
- when :tool
671
- { role: :tool, results: db_msg[:tool_results] || [] }
672
- else
673
- { role: db_msg[:role], content: db_msg[:content] }
674
- end
326
+ value
675
327
  end
676
328
  end
677
329
  end