prompt_objects 0.5.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +142 -0
- data/README.md +211 -19
- data/exe/poop +5 -0
- data/exe/prompt_objects +1 -1108
- data/exe/prompt_objects_mcp +5 -3
- data/lib/prompt_objects/capability.rb +33 -0
- data/lib/prompt_objects/cli/diagnostics_command.rb +69 -0
- data/lib/prompt_objects/cli/env_command.rb +435 -0
- data/lib/prompt_objects/cli/events_command.rb +145 -0
- data/lib/prompt_objects/cli/explore_command.rb +155 -0
- data/lib/prompt_objects/cli/helpers.rb +91 -0
- data/lib/prompt_objects/cli/message_command.rb +216 -0
- data/lib/prompt_objects/cli/repl_command.rb +366 -0
- data/lib/prompt_objects/cli/serve_command.rb +162 -0
- data/lib/prompt_objects/cli.rb +64 -422
- data/lib/prompt_objects/connectors/base.rb +16 -29
- data/lib/prompt_objects/connectors/mcp.rb +82 -98
- data/lib/prompt_objects/domain/records.rb +45 -0
- data/lib/prompt_objects/environment/exporter.rb +5 -4
- data/lib/prompt_objects/environment/git.rb +21 -1
- data/lib/prompt_objects/environment/importer.rb +5 -4
- data/lib/prompt_objects/environment/manager.rb +44 -6
- data/lib/prompt_objects/environment/manifest.rb +26 -4
- data/lib/prompt_objects/environment.rb +1350 -18
- data/lib/prompt_objects/execution/blocking_executor.rb +88 -0
- data/lib/prompt_objects/execution/context.rb +137 -0
- data/lib/prompt_objects/execution/contracts.rb +59 -0
- data/lib/prompt_objects/execution/permit_pool.rb +49 -0
- data/lib/prompt_objects/execution/policy.rb +47 -0
- data/lib/prompt_objects/execution/resource_lock_manager.rb +63 -0
- data/lib/prompt_objects/execution/runner.rb +498 -0
- data/lib/prompt_objects/execution/scheduler.rb +325 -0
- data/lib/prompt_objects/llm/factory.rb +4 -4
- data/lib/prompt_objects/llm/gemini_adapter.rb +25 -8
- data/lib/prompt_objects/llm/openai_adapter.rb +9 -1
- data/lib/prompt_objects/llm/pricing.rb +9 -2
- data/lib/prompt_objects/llm/response.rb +9 -4
- data/lib/prompt_objects/loader.rb +28 -1
- data/lib/prompt_objects/mcp/server.rb +6 -3
- data/lib/prompt_objects/mcp/tools/get_conversation.rb +15 -11
- data/lib/prompt_objects/mcp/tools/get_pending_requests.rb +2 -2
- data/lib/prompt_objects/mcp/tools/respond_to_request.rb +3 -3
- data/lib/prompt_objects/mcp/tools/send_message.rb +42 -32
- data/lib/prompt_objects/operations/diagnostics.rb +115 -0
- data/lib/prompt_objects/operations/retention.rb +75 -0
- data/lib/prompt_objects/operations/run_trace_builder.rb +152 -0
- data/lib/prompt_objects/primitives/http_get.rb +2 -0
- data/lib/prompt_objects/primitives/list_files.rb +2 -0
- data/lib/prompt_objects/primitives/read_file.rb +2 -0
- data/lib/prompt_objects/primitives/think.rb +36 -0
- data/lib/prompt_objects/primitives/write_file.rb +9 -0
- data/lib/prompt_objects/prompt_object.rb +190 -432
- data/lib/prompt_objects/repositories/artifact_repository.rb +109 -0
- data/lib/prompt_objects/repositories/base_repository.rb +37 -0
- data/lib/prompt_objects/repositories/definition_repository.rb +36 -0
- data/lib/prompt_objects/repositories/env_data_repository.rb +103 -0
- data/lib/prompt_objects/repositories/event_repository.rb +105 -0
- data/lib/prompt_objects/repositories/message_repository.rb +73 -0
- data/lib/prompt_objects/repositories/run_repository.rb +254 -0
- data/lib/prompt_objects/repositories/thread_repository.rb +87 -0
- data/lib/prompt_objects/repositories/tool_execution_repository.rb +88 -0
- data/lib/prompt_objects/repositories/workspace_session_repository.rb +61 -0
- data/lib/prompt_objects/server/api/routes.rb +561 -52
- data/lib/prompt_objects/server/app.rb +134 -1
- data/lib/prompt_objects/server/file_watcher.rb +9 -0
- data/lib/prompt_objects/server/public/assets/index-BjKlODDD.js +90 -0
- data/lib/prompt_objects/server/public/assets/index-w3fpIb5c.css +1 -0
- data/lib/prompt_objects/server/public/index.html +2 -2
- data/lib/prompt_objects/server/websocket_handler.rb +202 -207
- data/lib/prompt_objects/server.rb +170 -1
- data/lib/prompt_objects/service.rb +129 -0
- data/lib/prompt_objects/session/store.rb +774 -15
- data/lib/prompt_objects/universal/add_capability.rb +1 -1
- data/lib/prompt_objects/universal/create_capability.rb +4 -2
- data/lib/prompt_objects/universal/delete_env_data.rb +78 -0
- data/lib/prompt_objects/universal/delete_primitive.rb +1 -1
- data/lib/prompt_objects/universal/dispatch_po.rb +54 -0
- data/lib/prompt_objects/universal/get_env_data.rb +58 -0
- data/lib/prompt_objects/universal/get_run_status.rb +30 -0
- data/lib/prompt_objects/universal/join_runs.rb +35 -0
- data/lib/prompt_objects/universal/list_env_data.rb +57 -0
- data/lib/prompt_objects/universal/modify_primitive.rb +1 -1
- data/lib/prompt_objects/universal/render_artifact.rb +91 -0
- data/lib/prompt_objects/universal/request_primitive.rb +1 -1
- data/lib/prompt_objects/universal/set_env_data.rb +101 -0
- data/lib/prompt_objects/version.rb +5 -0
- data/lib/prompt_objects.rb +36 -6
- data/objects/coordinator.md +8 -4
- data/templates/arc-agi-1/objects/data_manager.md +2 -2
- data/templates/arc-agi-1/objects/observer.md +6 -2
- data/templates/arc-agi-1/objects/solver.md +14 -4
- data/templates/arc-agi-1/objects/verifier.md +6 -2
- data/templates/arc-agi-3/manifest.yml +19 -0
- data/templates/arc-agi-3/objects/analyst.md +79 -0
- data/templates/arc-agi-3/objects/player.md +76 -0
- data/templates/arc-agi-3/objects/strategist.md +97 -0
- data/templates/arc-agi-3/primitives/action_log.rb +133 -0
- data/templates/arc-agi-3/primitives/arc3_api.rb +223 -0
- data/templates/arc-agi-3/primitives/arc3_scorecard.rb +131 -0
- data/templates/arc-agi-3/primitives/frame_diff.rb +143 -0
- data/templates/arc-agi-3/primitives/frame_info.rb +96 -0
- data/templates/arc-agi-3/primitives/render_frame.rb +140 -0
- data/templates/basic/objects/basic.md +11 -3
- data/templates/chiptune/extras/sonic_pi_boot.rb +220 -0
- data/templates/chiptune/manifest.yml +20 -0
- data/templates/chiptune/objects/conductor.md +184 -0
- data/templates/chiptune/objects/sonic_pi.md +65 -0
- data/templates/chiptune/objects/voice_bass.md +44 -0
- data/templates/chiptune/objects/voice_lead.md +44 -0
- data/templates/chiptune/objects/voice_pad.md +43 -0
- data/templates/chiptune/objects/voice_perc.md +45 -0
- data/templates/chiptune/primitives/play_note.rb +59 -0
- data/templates/chiptune/primitives/play_sequence.rb +55 -0
- data/templates/chiptune/primitives/query_sonic_pi.rb +141 -0
- data/templates/chiptune/primitives/set_synth.rb +57 -0
- data/templates/chiptune/primitives/set_tempo.rb +45 -0
- data/templates/chiptune/primitives/stop_voice.rb +46 -0
- data/templates/chiptune/primitives/support/osc_bridge.rb +33 -0
- data/templates/chiptune/vendor/osc-ruby-1.1.5/LICENSE +19 -0
- data/templates/chiptune/vendor/osc-ruby-1.1.5/lib/osc-ruby/address_pattern.rb +58 -0
- data/templates/chiptune/vendor/osc-ruby-1.1.5/lib/osc-ruby/broadcast_client.rb +24 -0
- data/templates/chiptune/vendor/osc-ruby-1.1.5/lib/osc-ruby/bundle.rb +46 -0
- data/templates/chiptune/vendor/osc-ruby-1.1.5/lib/osc-ruby/client.rb +13 -0
- data/templates/chiptune/vendor/osc-ruby-1.1.5/lib/osc-ruby/core_ext/time.rb +6 -0
- data/templates/chiptune/vendor/osc-ruby-1.1.5/lib/osc-ruby/em_server.rb +63 -0
- data/templates/chiptune/vendor/osc-ruby-1.1.5/lib/osc-ruby/message.rb +46 -0
- data/templates/chiptune/vendor/osc-ruby-1.1.5/lib/osc-ruby/network_packet.rb +42 -0
- data/templates/chiptune/vendor/osc-ruby-1.1.5/lib/osc-ruby/osc_argument.rb +26 -0
- data/templates/chiptune/vendor/osc-ruby-1.1.5/lib/osc-ruby/osc_packet.rb +140 -0
- data/templates/chiptune/vendor/osc-ruby-1.1.5/lib/osc-ruby/osc_types.rb +53 -0
- data/templates/chiptune/vendor/osc-ruby-1.1.5/lib/osc-ruby/server.rb +91 -0
- data/templates/chiptune/vendor/osc-ruby-1.1.5/lib/osc-ruby.rb +18 -0
- data/templates/developer/objects/code_reviewer.md +1 -1
- data/templates/developer/objects/coordinator.md +16 -3
- data/templates/developer/objects/debugger.md +3 -3
- data/templates/empty/objects/assistant.md +10 -5
- data/templates/minimal/objects/assistant.md +10 -5
- data/templates/music/extras/sonic_pi_boot.rb +220 -0
- data/templates/music/manifest.yml +16 -0
- data/templates/music/objects/conductor.md +154 -0
- data/templates/music/objects/sonic_pi.md +65 -0
- data/templates/music/primitives/play_note.rb +55 -0
- data/templates/music/primitives/play_sequence.rb +51 -0
- data/templates/music/primitives/query_sonic_pi.rb +139 -0
- data/templates/music/primitives/set_synth.rb +53 -0
- data/templates/music/primitives/set_tempo.rb +43 -0
- data/templates/music/primitives/stop_voice.rb +44 -0
- data/templates/music/primitives/support/osc_bridge.rb +33 -0
- data/templates/music/vendor/osc-ruby-1.1.5/LICENSE +19 -0
- data/templates/music/vendor/osc-ruby-1.1.5/lib/osc-ruby/address_pattern.rb +58 -0
- data/templates/music/vendor/osc-ruby-1.1.5/lib/osc-ruby/broadcast_client.rb +24 -0
- data/templates/music/vendor/osc-ruby-1.1.5/lib/osc-ruby/bundle.rb +46 -0
- data/templates/music/vendor/osc-ruby-1.1.5/lib/osc-ruby/client.rb +13 -0
- data/templates/music/vendor/osc-ruby-1.1.5/lib/osc-ruby/core_ext/time.rb +6 -0
- data/templates/music/vendor/osc-ruby-1.1.5/lib/osc-ruby/em_server.rb +63 -0
- data/templates/music/vendor/osc-ruby-1.1.5/lib/osc-ruby/message.rb +46 -0
- data/templates/music/vendor/osc-ruby-1.1.5/lib/osc-ruby/network_packet.rb +42 -0
- data/templates/music/vendor/osc-ruby-1.1.5/lib/osc-ruby/osc_argument.rb +26 -0
- data/templates/music/vendor/osc-ruby-1.1.5/lib/osc-ruby/osc_packet.rb +140 -0
- data/templates/music/vendor/osc-ruby-1.1.5/lib/osc-ruby/osc_types.rb +53 -0
- data/templates/music/vendor/osc-ruby-1.1.5/lib/osc-ruby/server.rb +91 -0
- data/templates/music/vendor/osc-ruby-1.1.5/lib/osc-ruby.rb +18 -0
- data/templates/music-beginner/README.md +100 -0
- data/templates/music-beginner/extras/README.md +27 -0
- data/templates/music-beginner/extras/sonic_pi_boot.rb +297 -0
- data/templates/music-beginner/manifest.yml +25 -0
- data/templates/music-beginner/objects/conductor.md +222 -0
- data/templates/music-beginner/objects/engine_setup.md +54 -0
- data/templates/music-beginner/objects/voice_bass.md +43 -0
- data/templates/music-beginner/objects/voice_keys.md +48 -0
- data/templates/music-beginner/objects/voice_pad.md +46 -0
- data/templates/music-beginner/objects/voice_percussion.md +45 -0
- data/templates/music-beginner/objects/voice_texture.md +37 -0
- data/templates/music-beginner/primitives/play_pattern.rb +118 -0
- data/templates/music-beginner/primitives/query_music_engine.rb +94 -0
- data/templates/music-beginner/primitives/set_instrument.rb +47 -0
- data/templates/music-beginner/primitives/set_tempo.rb +43 -0
- data/templates/music-beginner/primitives/set_voice_level.rb +46 -0
- data/templates/music-beginner/primitives/stop_voice.rb +37 -0
- data/templates/music-beginner/primitives/support/osc_bridge.rb +25 -0
- data/templates/music-beginner/vendor/osc-ruby-1.1.5/LICENSE +19 -0
- data/templates/music-beginner/vendor/osc-ruby-1.1.5/lib/osc-ruby/address_pattern.rb +58 -0
- data/templates/music-beginner/vendor/osc-ruby-1.1.5/lib/osc-ruby/broadcast_client.rb +24 -0
- data/templates/music-beginner/vendor/osc-ruby-1.1.5/lib/osc-ruby/bundle.rb +46 -0
- data/templates/music-beginner/vendor/osc-ruby-1.1.5/lib/osc-ruby/client.rb +13 -0
- data/templates/music-beginner/vendor/osc-ruby-1.1.5/lib/osc-ruby/core_ext/time.rb +6 -0
- data/templates/music-beginner/vendor/osc-ruby-1.1.5/lib/osc-ruby/em_server.rb +63 -0
- data/templates/music-beginner/vendor/osc-ruby-1.1.5/lib/osc-ruby/message.rb +46 -0
- data/templates/music-beginner/vendor/osc-ruby-1.1.5/lib/osc-ruby/network_packet.rb +42 -0
- data/templates/music-beginner/vendor/osc-ruby-1.1.5/lib/osc-ruby/osc_argument.rb +26 -0
- data/templates/music-beginner/vendor/osc-ruby-1.1.5/lib/osc-ruby/osc_packet.rb +140 -0
- data/templates/music-beginner/vendor/osc-ruby-1.1.5/lib/osc-ruby/osc_types.rb +53 -0
- data/templates/music-beginner/vendor/osc-ruby-1.1.5/lib/osc-ruby/server.rb +91 -0
- data/templates/music-beginner/vendor/osc-ruby-1.1.5/lib/osc-ruby.rb +18 -0
- data/templates/music-pro/extras/AUDITION.md +100 -0
- data/templates/music-pro/extras/sonic_pi_boot.rb +546 -0
- data/templates/music-pro/manifest.yml +19 -0
- data/templates/music-pro/objects/conductor.md +126 -0
- data/templates/music-pro/objects/sonic_pi.md +35 -0
- data/templates/music-pro/objects/voice_harmony.md +48 -0
- data/templates/music-pro/objects/voice_rhythm.md +43 -0
- data/templates/music-pro/objects/voice_texture.md +42 -0
- data/templates/music-pro/primitives/define_patch.rb +46 -0
- data/templates/music-pro/primitives/query_music_engine.rb +69 -0
- data/templates/music-pro/primitives/set_live_pattern.rb +70 -0
- data/templates/music-pro/primitives/set_tempo.rb +31 -0
- data/templates/music-pro/primitives/stop_voice.rb +31 -0
- data/templates/music-pro/primitives/support/music_protocol.rb +136 -0
- data/templates/music-pro/primitives/support/osc_bridge.rb +33 -0
- data/templates/music-pro/primitives/trigger_event.rb +44 -0
- data/templates/music-pro/vendor/osc-ruby-1.1.5/LICENSE +19 -0
- data/templates/music-pro/vendor/osc-ruby-1.1.5/lib/osc-ruby/address_pattern.rb +58 -0
- data/templates/music-pro/vendor/osc-ruby-1.1.5/lib/osc-ruby/broadcast_client.rb +24 -0
- data/templates/music-pro/vendor/osc-ruby-1.1.5/lib/osc-ruby/bundle.rb +46 -0
- data/templates/music-pro/vendor/osc-ruby-1.1.5/lib/osc-ruby/client.rb +13 -0
- data/templates/music-pro/vendor/osc-ruby-1.1.5/lib/osc-ruby/core_ext/time.rb +6 -0
- data/templates/music-pro/vendor/osc-ruby-1.1.5/lib/osc-ruby/em_server.rb +63 -0
- data/templates/music-pro/vendor/osc-ruby-1.1.5/lib/osc-ruby/message.rb +46 -0
- data/templates/music-pro/vendor/osc-ruby-1.1.5/lib/osc-ruby/network_packet.rb +42 -0
- data/templates/music-pro/vendor/osc-ruby-1.1.5/lib/osc-ruby/osc_argument.rb +26 -0
- data/templates/music-pro/vendor/osc-ruby-1.1.5/lib/osc-ruby/osc_packet.rb +140 -0
- data/templates/music-pro/vendor/osc-ruby-1.1.5/lib/osc-ruby/osc_types.rb +53 -0
- data/templates/music-pro/vendor/osc-ruby-1.1.5/lib/osc-ruby/server.rb +91 -0
- data/templates/music-pro/vendor/osc-ruby-1.1.5/lib/osc-ruby.rb +18 -0
- data/templates/writer/objects/coordinator.md +14 -3
- data/templates/writer/objects/editor.md +1 -1
- data/templates/writer/objects/researcher.md +2 -2
- data/tools/thread-explorer.html +28 -1
- metadata +175 -65
- data/CLAUDE.md +0 -176
- data/Gemfile +0 -10
- data/Gemfile.lock +0 -233
- data/IMPLEMENTATION_PLAN.md +0 -1073
- data/Rakefile +0 -27
- data/design-doc-v2.md +0 -1232
- data/frontend/.gitignore +0 -3
- data/frontend/index.html +0 -17
- data/frontend/package-lock.json +0 -4540
- data/frontend/package.json +0 -36
- data/frontend/postcss.config.js +0 -6
- data/frontend/src/App.tsx +0 -103
- data/frontend/src/canvas/CanvasView.tsx +0 -113
- data/frontend/src/canvas/ForceLayout.ts +0 -115
- data/frontend/src/canvas/SceneManager.ts +0 -587
- data/frontend/src/canvas/canvasStore.ts +0 -47
- data/frontend/src/canvas/constants.ts +0 -95
- data/frontend/src/canvas/controls/CameraControls.ts +0 -98
- data/frontend/src/canvas/edges/MessageArc.ts +0 -149
- data/frontend/src/canvas/inspector/InspectorPanel.tsx +0 -31
- data/frontend/src/canvas/inspector/POInspector.tsx +0 -262
- data/frontend/src/canvas/inspector/ToolCallInspector.tsx +0 -67
- data/frontend/src/canvas/nodes/PONode.ts +0 -249
- data/frontend/src/canvas/nodes/ToolCallNode.ts +0 -116
- data/frontend/src/canvas/types.ts +0 -24
- data/frontend/src/components/ContextMenu.tsx +0 -68
- data/frontend/src/components/Inspector.tsx +0 -232
- data/frontend/src/components/MarkdownMessage.tsx +0 -155
- data/frontend/src/components/MethodList.tsx +0 -90
- data/frontend/src/components/ModelSelector.tsx +0 -115
- data/frontend/src/components/NotificationPanel.tsx +0 -130
- data/frontend/src/components/ObjectList.tsx +0 -78
- data/frontend/src/components/PaneSlot.tsx +0 -30
- data/frontend/src/components/SourcePane.tsx +0 -202
- data/frontend/src/components/SystemBar.tsx +0 -74
- data/frontend/src/components/Transcript.tsx +0 -76
- data/frontend/src/components/UsagePanel.tsx +0 -105
- data/frontend/src/components/Workspace.tsx +0 -260
- data/frontend/src/components/index.ts +0 -12
- data/frontend/src/hooks/useResize.ts +0 -55
- data/frontend/src/hooks/useWebSocket.ts +0 -517
- data/frontend/src/index.css +0 -103
- data/frontend/src/main.tsx +0 -10
- data/frontend/src/store/index.ts +0 -279
- data/frontend/src/types/index.ts +0 -165
- data/frontend/tailwind.config.js +0 -44
- data/frontend/tsconfig.json +0 -30
- data/frontend/vite.config.ts +0 -29
- data/lib/prompt_objects/server/public/assets/index-D1myxE0l.js +0 -4345
- data/lib/prompt_objects/server/public/assets/index-DdCcwC-Z.css +0 -1
- data/lib/prompt_objects/universal/add_primitive.rb +0 -118
- data/lib/prompt_objects/universal/ask_human.rb +0 -109
- data/lib/prompt_objects/universal/create_primitive.rb +0 -174
- data/lib/prompt_objects/universal/list_primitives.rb +0 -145
- data/lib/prompt_objects/universal/think.rb +0 -41
- data/prompt_objects.gemspec +0 -50
- data/templates/basic/.gitignore +0 -2
- data/templates/developer/.gitignore +0 -5
- data/templates/empty/.gitignore +0 -5
- data/templates/empty/objects/.gitkeep +0 -0
- data/templates/minimal/.gitignore +0 -5
- data/templates/writer/.gitignore +0 -5
|
@@ -16,6 +16,19 @@ module PromptObjects
|
|
|
16
16
|
@tui_mode = false
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
+
# Resolve the workspace scope for shared environment data.
|
|
20
|
+
# @return [String, nil] Scope id, or nil if it can't be resolved
|
|
21
|
+
def env_data_scope
|
|
22
|
+
store = env.session_store
|
|
23
|
+
return nil unless store
|
|
24
|
+
|
|
25
|
+
if env.respond_to?(:env_data_scope)
|
|
26
|
+
env.env_data_scope
|
|
27
|
+
elsif env.respond_to?(:active_session_id) && env.active_session_id
|
|
28
|
+
env.active_session_id
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
19
32
|
# Log a message to the bus.
|
|
20
33
|
# @param to [String] Destination capability
|
|
21
34
|
# @param message [String, Hash] The message
|
|
@@ -34,14 +47,31 @@ module PromptObjects
|
|
|
34
47
|
# The runtime environment that holds all capabilities and coordinates execution.
|
|
35
48
|
# Can be initialized either from:
|
|
36
49
|
# - A user environment directory (with manifest.yml, git integration)
|
|
37
|
-
# - A simple objects directory
|
|
50
|
+
# - A simple objects directory backed by an in-memory runtime store
|
|
38
51
|
class Runtime
|
|
52
|
+
DEFAULT_SCHEDULER_OPTIONS = {
|
|
53
|
+
worker_limit: 4,
|
|
54
|
+
provider_limit: 4,
|
|
55
|
+
tool_limit: 8,
|
|
56
|
+
cpu_limit: 2,
|
|
57
|
+
max_active_runs: 64,
|
|
58
|
+
max_active_per_po: 4,
|
|
59
|
+
max_queued_per_thread: 20,
|
|
60
|
+
max_delegation_depth: 8,
|
|
61
|
+
max_descendants: 64,
|
|
62
|
+
run_event_retention: Operations::Retention::DEFAULT_RUN_EVENT_LIMIT,
|
|
63
|
+
workspace_event_retention: Operations::Retention::DEFAULT_WORKSPACE_EVENT_LIMIT
|
|
64
|
+
}.freeze
|
|
65
|
+
|
|
39
66
|
attr_reader :llm, :registry, :objects_dir, :bus, :primitives_dir, :human_queue,
|
|
40
67
|
:manifest, :env_path, :auto_commit, :session_store,
|
|
41
|
-
:current_provider, :current_model
|
|
68
|
+
:current_provider, :current_model, :services, :connectors, :active_session_id,
|
|
69
|
+
:runtime_model
|
|
42
70
|
attr_accessor :on_po_registered # Callback for when a PO is registered
|
|
43
71
|
attr_accessor :on_po_modified # Callback for when a PO is modified (capabilities changed, etc.)
|
|
44
72
|
attr_accessor :on_delegation_event # Callback for PO-to-PO delegation start/complete
|
|
73
|
+
attr_accessor :on_env_data_changed # Callback for env data store/update/delete
|
|
74
|
+
attr_accessor :on_broadcast # Callback for services to push messages to all WebSocket clients
|
|
45
75
|
|
|
46
76
|
# Initialize from an environment path (with manifest) or objects directory.
|
|
47
77
|
# @param env_path [String, nil] Path to environment directory (preferred)
|
|
@@ -51,12 +81,15 @@ module PromptObjects
|
|
|
51
81
|
# @param provider [String, nil] LLM provider (openai, anthropic, gemini)
|
|
52
82
|
# @param model [String, nil] Model name
|
|
53
83
|
# @param auto_commit [Boolean] Auto-commit changes to git (default: true for env_path)
|
|
54
|
-
def initialize(env_path: nil, objects_dir: nil, primitives_dir: nil, llm: nil, provider: nil, model: nil,
|
|
84
|
+
def initialize(env_path: nil, objects_dir: nil, primitives_dir: nil, llm: nil, provider: nil, model: nil,
|
|
85
|
+
auto_commit: nil, runtime_model: nil, scheduler_options: nil)
|
|
55
86
|
if env_path
|
|
56
87
|
# Environment-based initialization
|
|
57
88
|
@env_path = env_path
|
|
58
89
|
@objects_dir = File.join(env_path, "objects")
|
|
59
90
|
@primitives_dir = primitives_dir || File.join(env_path, "primitives")
|
|
91
|
+
@services_dir = File.join(env_path, "services")
|
|
92
|
+
@connectors_dir = File.join(env_path, "connectors")
|
|
60
93
|
@manifest = Env::Manifest.load_from_dir(env_path)
|
|
61
94
|
@auto_commit = auto_commit.nil? ? true : auto_commit
|
|
62
95
|
@manifest.touch_opened!
|
|
@@ -70,10 +103,20 @@ module PromptObjects
|
|
|
70
103
|
@env_path = nil
|
|
71
104
|
@objects_dir = objects_dir || "objects"
|
|
72
105
|
@primitives_dir = primitives_dir || File.join(File.dirname(@objects_dir), "primitives")
|
|
106
|
+
@services_dir = nil
|
|
107
|
+
@connectors_dir = nil
|
|
73
108
|
@manifest = nil
|
|
74
109
|
@auto_commit = auto_commit || false
|
|
75
|
-
@session_store =
|
|
110
|
+
@session_store = Session::Store.new(":memory:")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
@runtime_model = "runs"
|
|
114
|
+
if @manifest && @manifest.runtime_model != "runs"
|
|
115
|
+
@manifest.runtime_model = "runs"
|
|
116
|
+
@manifest.save_to_dir(@env_path)
|
|
76
117
|
end
|
|
118
|
+
@scheduler_options = DEFAULT_SCHEDULER_OPTIONS.merge(scheduler_options || {})
|
|
119
|
+
@child_admission_mutex = Mutex.new
|
|
77
120
|
|
|
78
121
|
# Initialize LLM - prefer explicit llm, then use factory with provider/model
|
|
79
122
|
if llm
|
|
@@ -81,17 +124,43 @@ module PromptObjects
|
|
|
81
124
|
@current_provider = nil
|
|
82
125
|
@current_model = nil
|
|
83
126
|
else
|
|
84
|
-
|
|
85
|
-
@
|
|
127
|
+
manifest_llm = @manifest&.llm || {}
|
|
128
|
+
@current_provider = provider || manifest_llm["provider"] || LLM::Factory::DEFAULT_PROVIDER
|
|
129
|
+
manifest_model = manifest_llm["model"] unless provider
|
|
130
|
+
@current_model = model || manifest_model || LLM::Factory.default_model(@current_provider)
|
|
86
131
|
@llm = LLM::Factory.create(provider: @current_provider, model: @current_model)
|
|
87
132
|
end
|
|
88
133
|
|
|
89
134
|
@registry = Registry.new
|
|
90
135
|
@bus = MessageBus.new(session_store: @session_store)
|
|
91
136
|
@human_queue = HumanQueue.new
|
|
137
|
+
# In-memory boot-cycle activity. Autonomous POs can opt to stay dormant
|
|
138
|
+
# until a human has initialized them during this server run; persisted
|
|
139
|
+
# sessions from a previous run intentionally do not arm the timer.
|
|
140
|
+
@human_message_received = {}
|
|
141
|
+
@autonomous_schedule_states = {}
|
|
142
|
+
@autonomous_schedule_mutex = Mutex.new
|
|
143
|
+
|
|
144
|
+
# Paths the app just wrote itself (via PO#save / #update_source). The
|
|
145
|
+
# FileWatcher consults this so an in-app save doesn't trigger a redundant,
|
|
146
|
+
# disruptive reload of a PO that's mid-conversation — the in-memory PO is
|
|
147
|
+
# already up to date. Manual/external edits aren't registered, so they
|
|
148
|
+
# still hot-reload normally.
|
|
149
|
+
@suppressed_reloads = {}
|
|
150
|
+
@suppressed_reloads_mutex = Mutex.new
|
|
151
|
+
|
|
152
|
+
validate_dependencies if @manifest&.dependencies&.any?
|
|
92
153
|
|
|
93
154
|
register_primitives
|
|
94
155
|
register_universal_capabilities
|
|
156
|
+
|
|
157
|
+
@services = []
|
|
158
|
+
load_custom_services
|
|
159
|
+
|
|
160
|
+
@connectors = []
|
|
161
|
+
load_custom_connectors
|
|
162
|
+
|
|
163
|
+
recover_interrupted_runs
|
|
95
164
|
end
|
|
96
165
|
|
|
97
166
|
# Create runtime from a user environment by name.
|
|
@@ -111,7 +180,7 @@ module PromptObjects
|
|
|
111
180
|
@manifest&.name || File.basename(@objects_dir)
|
|
112
181
|
end
|
|
113
182
|
|
|
114
|
-
# Check if this
|
|
183
|
+
# Check if this runtime has a persisted environment directory.
|
|
115
184
|
# @return [Boolean]
|
|
116
185
|
def environment?
|
|
117
186
|
!@env_path.nil?
|
|
@@ -139,15 +208,24 @@ module PromptObjects
|
|
|
139
208
|
# @param model [String, nil] Model name (defaults to provider's default)
|
|
140
209
|
# @return [Hash] New provider/model info
|
|
141
210
|
def switch_llm(provider:, model: nil)
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
211
|
+
new_provider = provider
|
|
212
|
+
new_model = model || LLM::Factory.default_model(provider)
|
|
213
|
+
new_llm = LLM::Factory.create(provider: new_provider, model: new_model)
|
|
214
|
+
|
|
215
|
+
@current_provider = new_provider
|
|
216
|
+
@current_model = new_model
|
|
217
|
+
@llm = new_llm
|
|
145
218
|
|
|
146
219
|
# Update all loaded POs to use the new LLM
|
|
147
220
|
@registry.prompt_objects.each do |po|
|
|
148
221
|
po.instance_variable_set(:@llm, @llm)
|
|
149
222
|
end
|
|
150
223
|
|
|
224
|
+
if @manifest
|
|
225
|
+
@manifest.llm = { "provider" => @current_provider, "model" => @current_model }
|
|
226
|
+
@manifest.save_to_dir(@env_path)
|
|
227
|
+
end
|
|
228
|
+
|
|
151
229
|
{ provider: @current_provider, model: @current_model }
|
|
152
230
|
end
|
|
153
231
|
|
|
@@ -216,6 +294,27 @@ module PromptObjects
|
|
|
216
294
|
@on_po_modified&.call(po)
|
|
217
295
|
end
|
|
218
296
|
|
|
297
|
+
# Mark a file path as just-written-by-the-app, so the FileWatcher skips the
|
|
298
|
+
# next reload event for it. Idempotent; absolute paths recommended.
|
|
299
|
+
# @param path [String]
|
|
300
|
+
def suppress_next_reload(path)
|
|
301
|
+
return unless path
|
|
302
|
+
|
|
303
|
+
key = File.expand_path(path)
|
|
304
|
+
@suppressed_reloads_mutex.synchronize { @suppressed_reloads[key] = true }
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# Whether a pending in-app save should suppress the next reload of this path.
|
|
308
|
+
# Consumes the suppression (returns true at most once per registration).
|
|
309
|
+
# @param path [String]
|
|
310
|
+
# @return [Boolean]
|
|
311
|
+
def reload_suppressed?(path)
|
|
312
|
+
return false unless path
|
|
313
|
+
|
|
314
|
+
key = File.expand_path(path)
|
|
315
|
+
@suppressed_reloads_mutex.synchronize { !!@suppressed_reloads.delete(key) }
|
|
316
|
+
end
|
|
317
|
+
|
|
219
318
|
# Notify that a PO-to-PO delegation has started or completed.
|
|
220
319
|
# @param event_type [Symbol] :started or :completed
|
|
221
320
|
# @param payload [Hash] { target:, caller:, thread_id:, tool_call_id: }
|
|
@@ -223,6 +322,15 @@ module PromptObjects
|
|
|
223
322
|
@on_delegation_event&.call(event_type, payload)
|
|
224
323
|
end
|
|
225
324
|
|
|
325
|
+
# Notify that environment data has changed (stored, updated, or deleted).
|
|
326
|
+
# @param action [String] "store", "update", or "delete"
|
|
327
|
+
# @param root_thread_id [String] Root thread scope
|
|
328
|
+
# @param key [String] The data key that changed
|
|
329
|
+
# @param stored_by [String] PO name that made the change
|
|
330
|
+
def notify_env_data_changed(action:, root_thread_id:, key:, stored_by:)
|
|
331
|
+
@on_env_data_changed&.call(action: action, root_thread_id: root_thread_id, key: key, stored_by: stored_by)
|
|
332
|
+
end
|
|
333
|
+
|
|
226
334
|
# Load a prompt object by name from the objects directory.
|
|
227
335
|
# @param name [String] Name of the prompt object (without .md extension)
|
|
228
336
|
# @return [PromptObject]
|
|
@@ -267,22 +375,1162 @@ module PromptObjects
|
|
|
267
375
|
@registry.primitives.map(&:name)
|
|
268
376
|
end
|
|
269
377
|
|
|
378
|
+
# --- Environment Session API ---
|
|
379
|
+
|
|
380
|
+
# Get or create the active environment session.
|
|
381
|
+
# @param initiated_by [String] Who initiated the session (default: "human")
|
|
382
|
+
# @return [Hash] Session data
|
|
383
|
+
def active_session(initiated_by: "human")
|
|
384
|
+
@active_session_id ||= find_or_create_environment_session(initiated_by: initiated_by)
|
|
385
|
+
@session_store&.get_session(@active_session_id)
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# Resolve the env_data scope id for a given session, preferring the
|
|
389
|
+
# environment-level session (v8). Used by server-side readers (REST API,
|
|
390
|
+
# WebSocket, broadcasts) so they read from the same scope writers use.
|
|
391
|
+
# @param session_id [String, nil] A session to fall back to if no env session
|
|
392
|
+
# @return [String, nil] Scope id
|
|
393
|
+
def env_data_scope(session_id = nil)
|
|
394
|
+
return @active_session_id if @active_session_id
|
|
395
|
+
return nil unless @session_store && session_id
|
|
396
|
+
|
|
397
|
+
@session_store.resolve_root_thread(session_id)
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# Create a new environment session, making it the active one.
|
|
401
|
+
# @param name [String, nil] Optional session name
|
|
402
|
+
# @param initiated_by [String] Who initiated (human, timer, api, etc.)
|
|
403
|
+
# @return [Hash] Session data
|
|
404
|
+
def create_environment_session(name: nil, initiated_by: "human")
|
|
405
|
+
return nil unless @session_store
|
|
406
|
+
|
|
407
|
+
id = SecureRandom.uuid
|
|
408
|
+
workspace_repository.create(id: id, name: name)
|
|
409
|
+
id = @session_store.create_session(
|
|
410
|
+
id: id,
|
|
411
|
+
po_name: nil,
|
|
412
|
+
name: name,
|
|
413
|
+
source: "web",
|
|
414
|
+
thread_type: "root",
|
|
415
|
+
workspace_session_id: id
|
|
416
|
+
)
|
|
417
|
+
@active_session_id = id
|
|
418
|
+
@session_store.get_session(id)
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
# List the environment-level sessions (the shared-context "jams") with a
|
|
422
|
+
# flag marking which one is active. Side-effect-free: reads the already-
|
|
423
|
+
# resolved @active_session_id rather than calling active_session (which
|
|
424
|
+
# would create a session on read), and queries the store once.
|
|
425
|
+
# @return [Array<Hash>]
|
|
426
|
+
def environment_sessions
|
|
427
|
+
return [] unless @session_store
|
|
428
|
+
|
|
429
|
+
active = @active_session_id
|
|
430
|
+
@session_store.list_environment_sessions.map do |s|
|
|
431
|
+
s.merge(active: s[:id] == active)
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
# Switch the active environment session (changes the shared env_data scope).
|
|
436
|
+
# @param session_id [String]
|
|
437
|
+
# @return [Hash, nil] The now-active session, or nil if not found
|
|
438
|
+
def switch_active_session(session_id)
|
|
439
|
+
return nil unless @session_store
|
|
440
|
+
|
|
441
|
+
session = @session_store.get_session(session_id)
|
|
442
|
+
return nil unless session
|
|
443
|
+
|
|
444
|
+
@active_session_id = session_id
|
|
445
|
+
session
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
# Rename an environment session.
|
|
449
|
+
# @return [Hash, nil] The updated session
|
|
450
|
+
def rename_session(session_id, name)
|
|
451
|
+
return nil unless @session_store && @session_store.get_session(session_id)
|
|
452
|
+
|
|
453
|
+
@session_store.update_session(session_id, name: name)
|
|
454
|
+
@session_store.get_session(session_id)
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
# Delete an environment session (and its messages). If it was active, the
|
|
458
|
+
# next active_session call will find or create another.
|
|
459
|
+
# @return [Boolean]
|
|
460
|
+
def delete_environment_session(session_id)
|
|
461
|
+
return false unless @session_store && @session_store.get_session(session_id)
|
|
462
|
+
|
|
463
|
+
@session_store.delete_session(session_id)
|
|
464
|
+
@active_session_id = nil if @active_session_id == session_id
|
|
465
|
+
true
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
# Inject a message through the run scheduler. All sources share this entry
|
|
469
|
+
# point and differ only in their provenance and source-specific thread.
|
|
470
|
+
#
|
|
471
|
+
# @param target_po [String] Name of the PO to receive the message
|
|
472
|
+
# @param content [String] Message content
|
|
473
|
+
# @param from_source [String] Message source (human, timer:name, service:name, api)
|
|
474
|
+
# @return [String] The PO's response
|
|
475
|
+
def inject_message(target_po:, content:, from_source: "human",
|
|
476
|
+
workspace_session_id: nil, thread_id: nil, client_request_id: nil, deadline: nil)
|
|
477
|
+
po = @registry.get(target_po)
|
|
478
|
+
raise Error, "Unknown prompt object: #{target_po}" unless po.is_a?(PromptObject)
|
|
479
|
+
|
|
480
|
+
inject_run_message(
|
|
481
|
+
po: po,
|
|
482
|
+
content: content,
|
|
483
|
+
from_source: from_source,
|
|
484
|
+
workspace_session_id: workspace_session_id,
|
|
485
|
+
thread_id: thread_id,
|
|
486
|
+
client_request_id: client_request_id,
|
|
487
|
+
deadline: deadline
|
|
488
|
+
)
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
def enqueue_run(target_po:, content:, from_source: "human", workspace_session_id: nil,
|
|
492
|
+
thread_id: nil, client_request_id: nil, deadline: nil)
|
|
493
|
+
po = @registry.get(target_po)
|
|
494
|
+
raise Error, "Unknown prompt object: #{target_po}" unless po.is_a?(PromptObject)
|
|
495
|
+
|
|
496
|
+
workspace_id = resolve_workspace_id(workspace_session_id, initiated_by: from_source)
|
|
497
|
+
thread = resolve_run_thread(
|
|
498
|
+
workspace_id: workspace_id,
|
|
499
|
+
po: po,
|
|
500
|
+
from_source: from_source,
|
|
501
|
+
thread_id: thread_id
|
|
502
|
+
)
|
|
503
|
+
# enqueue_run is the canonical entry point used by WebSocket, REST, CLI,
|
|
504
|
+
# connectors, and inject_message. Arm wait-for-human schedules here so a
|
|
505
|
+
# valid human run wakes autonomous POs regardless of which surface sent it.
|
|
506
|
+
@human_message_received[target_po] = true if from_source == "human"
|
|
507
|
+
definition = execution_definition_for(po)
|
|
508
|
+
source_type = source_type_for(from_source)
|
|
509
|
+
if source_type == "autonomous"
|
|
510
|
+
active = run_repository.active_for_source(
|
|
511
|
+
workspace_session_id: workspace_id,
|
|
512
|
+
po_name: po.name,
|
|
513
|
+
source_type: source_type,
|
|
514
|
+
source_name: from_source
|
|
515
|
+
)
|
|
516
|
+
return active if active
|
|
517
|
+
end
|
|
518
|
+
normalized_deadline = normalize_deadline(deadline)
|
|
519
|
+
run, created = run_repository.enqueue_with_result(
|
|
520
|
+
workspace_session_id: workspace_id,
|
|
521
|
+
thread_id: thread.id,
|
|
522
|
+
po_name: po.name,
|
|
523
|
+
input_content: content,
|
|
524
|
+
definition_version: definition.version,
|
|
525
|
+
provider: definition.provider,
|
|
526
|
+
model: definition.model,
|
|
527
|
+
capability_policy_version: definition.capability_policy_version,
|
|
528
|
+
source_type: source_type,
|
|
529
|
+
source_name: from_source,
|
|
530
|
+
client_request_id: client_request_id,
|
|
531
|
+
priority: priority_for(from_source),
|
|
532
|
+
metadata: { deadline: normalized_deadline&.iso8601(6) }.compact
|
|
533
|
+
)
|
|
534
|
+
definition_repository.attach(run_id: run.id, definition: definition) unless definition_repository.get(run.id)
|
|
535
|
+
if created
|
|
536
|
+
broadcast_workspace_snapshot(workspace_id)
|
|
537
|
+
publish_run_event(event_repository.append_run_event(
|
|
538
|
+
run_id: run.id,
|
|
539
|
+
kind: "run_queued",
|
|
540
|
+
data: { source_type: run.source_type, turn_ordinal: run.turn_ordinal }
|
|
541
|
+
))
|
|
542
|
+
broadcast_po_runtime_status(run)
|
|
543
|
+
end
|
|
544
|
+
run
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
def execution_definition_for(po)
|
|
548
|
+
po.execution_definition(
|
|
549
|
+
provider: current_provider,
|
|
550
|
+
model: current_model,
|
|
551
|
+
capability_policy_version: capability_policy_version_for_config(po.config)
|
|
552
|
+
)
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
def capability_policy_version_for_config(config)
|
|
556
|
+
names = Array(config["capabilities"] || config[:capabilities]) + UNIVERSAL_CAPABILITIES
|
|
557
|
+
signatures = names.uniq.sort.map do |name|
|
|
558
|
+
capability = registry.get(name)
|
|
559
|
+
[name, capability&.execution_policy_signature || "missing"]
|
|
560
|
+
end
|
|
561
|
+
Digest::SHA256.hexdigest(JSON.generate(signatures))
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
def execute_run(run_id, tui_mode: true)
|
|
565
|
+
run = run_repository.get(run_id)
|
|
566
|
+
raise Error, "Unknown run: #{run_id}" unless run
|
|
567
|
+
return run.final_response if run.status == "completed"
|
|
568
|
+
return :queued unless run.status == "queued"
|
|
569
|
+
|
|
570
|
+
run_scheduler.submit(run, deadline: run_deadline(run), tui_mode: tui_mode).wait
|
|
571
|
+
rescue Execution::Scheduler::RejectedError => e
|
|
572
|
+
reject_queued_run(run, e.message)
|
|
573
|
+
raise Error, e.message
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
def execute_message_run(target_po:, content:, from_source:, workspace_session_id: nil,
|
|
577
|
+
thread_id: nil, client_request_id: nil, deadline: nil, tui_mode: false)
|
|
578
|
+
run = enqueue_run(
|
|
579
|
+
target_po: target_po,
|
|
580
|
+
content: content,
|
|
581
|
+
from_source: from_source,
|
|
582
|
+
workspace_session_id: workspace_session_id,
|
|
583
|
+
thread_id: thread_id,
|
|
584
|
+
client_request_id: client_request_id,
|
|
585
|
+
deadline: deadline
|
|
586
|
+
)
|
|
587
|
+
response = execute_run(run.id, tui_mode: tui_mode)
|
|
588
|
+
[run_repository.get(run.id), response]
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
def cancel_run(run_id, reason: "cancelled")
|
|
592
|
+
run = run_repository.get(run_id)
|
|
593
|
+
return false unless run
|
|
594
|
+
|
|
595
|
+
cancelled = @run_scheduler&.cancel(run_id, reason: reason)
|
|
596
|
+
unless cancelled
|
|
597
|
+
return false unless run.status == "queued"
|
|
598
|
+
|
|
599
|
+
cancel_queued_run(run, reason)
|
|
600
|
+
cancelled = true
|
|
601
|
+
end
|
|
602
|
+
run_repository.active_children(parent_run_id: run_id).each do |child|
|
|
603
|
+
next if metadata_value(child, :child_lifetime) == "detached"
|
|
604
|
+
|
|
605
|
+
cancel_run(child.id, reason: "parent cancelled: #{reason}")
|
|
606
|
+
end
|
|
607
|
+
cancelled
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
def schedule_run(run_id, tui_mode: true)
|
|
611
|
+
run = run_repository.get(run_id)
|
|
612
|
+
raise Error, "Unknown run: #{run_id}" unless run
|
|
613
|
+
return nil unless run.status == "queued"
|
|
614
|
+
|
|
615
|
+
run_scheduler.submit(run, deadline: run_deadline(run), tui_mode: tui_mode)
|
|
616
|
+
rescue Execution::Scheduler::RejectedError => e
|
|
617
|
+
reject_queued_run(run, e.message)
|
|
618
|
+
raise Error, e.message
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
def with_child_run_admission(parent)
|
|
622
|
+
@child_admission_mutex.synchronize do
|
|
623
|
+
depth = run_depth(parent) + 1
|
|
624
|
+
raise Error, "Maximum delegation depth exceeded" if depth > @scheduler_options.fetch(:max_delegation_depth)
|
|
625
|
+
if run_repository.descendant_count(root_run_id: parent.root_run_id) >= @scheduler_options.fetch(:max_descendants)
|
|
626
|
+
raise Error, "Maximum run descendant count exceeded"
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
yield depth
|
|
630
|
+
end
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
def dispatch_run(target_po:, content:, context:, thread_id: nil, lifetime: "attached", on_complete: nil)
|
|
634
|
+
raise Error, "Dispatch requires a run context" unless context.run_id
|
|
635
|
+
raise Error, "lifetime must be attached or detached" unless %w[attached detached].include?(lifetime)
|
|
636
|
+
|
|
637
|
+
parent = run_repository.get(context.run_id)
|
|
638
|
+
raise Error, "Unknown parent run: #{context.run_id}" unless parent
|
|
639
|
+
validate_dispatch_target!(parent.po_name, target_po)
|
|
640
|
+
target = registry.get(target_po)
|
|
641
|
+
definition = execution_definition_for(target)
|
|
642
|
+
thread = nil
|
|
643
|
+
child = nil
|
|
644
|
+
with_child_run_admission(parent) do |depth|
|
|
645
|
+
thread = if thread_id
|
|
646
|
+
required_dispatch_thread(thread_id, parent, target_po)
|
|
647
|
+
else
|
|
648
|
+
thread_repository.create(
|
|
649
|
+
workspace_session_id: parent.workspace_session_id,
|
|
650
|
+
po_name: target_po,
|
|
651
|
+
thread_type: "delegation",
|
|
652
|
+
parent_thread_id: parent.thread_id,
|
|
653
|
+
parent_run_id: parent.id,
|
|
654
|
+
retention_policy: "full"
|
|
655
|
+
)
|
|
656
|
+
end
|
|
657
|
+
child = run_repository.enqueue(
|
|
658
|
+
workspace_session_id: parent.workspace_session_id,
|
|
659
|
+
thread_id: thread.id,
|
|
660
|
+
po_name: target_po,
|
|
661
|
+
input_content: content,
|
|
662
|
+
definition_version: definition.version,
|
|
663
|
+
provider: definition.provider,
|
|
664
|
+
model: definition.model,
|
|
665
|
+
capability_policy_version: definition.capability_policy_version,
|
|
666
|
+
source_type: "delegation",
|
|
667
|
+
source_name: parent.po_name,
|
|
668
|
+
parent_run_id: parent.id,
|
|
669
|
+
priority: 80,
|
|
670
|
+
metadata: {
|
|
671
|
+
dispatch_mode: "dispatch",
|
|
672
|
+
child_lifetime: lifetime,
|
|
673
|
+
delegation_depth: depth,
|
|
674
|
+
deadline: context.deadline&.iso8601(6),
|
|
675
|
+
on_complete: on_complete
|
|
676
|
+
}
|
|
677
|
+
)
|
|
678
|
+
end
|
|
679
|
+
definition_repository.attach(run_id: child.id, definition: definition)
|
|
680
|
+
publish_workspace_snapshot(parent.workspace_session_id)
|
|
681
|
+
publish_run_event(event_repository.append_run_event(
|
|
682
|
+
run_id: child.id,
|
|
683
|
+
kind: "run_queued",
|
|
684
|
+
data: { source_type: child.source_type, turn_ordinal: child.turn_ordinal, mode: "dispatch" }
|
|
685
|
+
))
|
|
686
|
+
publish_run_event(event_repository.append_run_event(
|
|
687
|
+
run_id: parent.id,
|
|
688
|
+
kind: "delegation_started",
|
|
689
|
+
data: {
|
|
690
|
+
child_run_id: child.id,
|
|
691
|
+
child_thread_id: thread.id,
|
|
692
|
+
target_po: target_po,
|
|
693
|
+
mode: "dispatch",
|
|
694
|
+
lifetime: lifetime
|
|
695
|
+
}
|
|
696
|
+
))
|
|
697
|
+
schedule_run(child.id, tui_mode: context.tui_mode)
|
|
698
|
+
{ run_id: child.id, thread_id: thread.id, status: child.status, lifetime: lifetime }
|
|
699
|
+
end
|
|
700
|
+
|
|
701
|
+
def join_runs(run_ids, context:, timeout: nil)
|
|
702
|
+
Array(run_ids).map { |run_id| join_run(run_id, context: context, timeout: timeout) }
|
|
703
|
+
end
|
|
704
|
+
|
|
705
|
+
def run_status(run_id, context:)
|
|
706
|
+
run = authorized_related_run(run_id, context)
|
|
707
|
+
serialize_runtime_value(run.to_h)
|
|
708
|
+
end
|
|
709
|
+
|
|
710
|
+
def join_run(run_id, context:, timeout: nil)
|
|
711
|
+
run = authorized_related_run(run_id, context)
|
|
712
|
+
unless terminal_run_status?(run.status)
|
|
713
|
+
ticket = run_scheduler.ticket(run.id)
|
|
714
|
+
ticket ||= schedule_run(run.id, tui_mode: context.tui_mode)
|
|
715
|
+
ticket = run_scheduler.help(run.id) || ticket
|
|
716
|
+
ticket&.wait(timeout: timeout)
|
|
717
|
+
run = run_repository.get(run.id)
|
|
718
|
+
end
|
|
719
|
+
{
|
|
720
|
+
run_id: run.id,
|
|
721
|
+
thread_id: run.thread_id,
|
|
722
|
+
status: run.status,
|
|
723
|
+
response: run.final_response,
|
|
724
|
+
error: run.error_message
|
|
725
|
+
}
|
|
726
|
+
rescue Timeout::Error
|
|
727
|
+
{ run_id: run_id, status: "waiting", error: "join timed out" }
|
|
728
|
+
end
|
|
729
|
+
|
|
730
|
+
def shutdown_execution(wait: true)
|
|
731
|
+
@run_scheduler&.shutdown(wait: wait)
|
|
732
|
+
@run_scheduler = nil
|
|
733
|
+
end
|
|
734
|
+
|
|
735
|
+
def operational_diagnostics
|
|
736
|
+
Operations::Diagnostics.new(@session_store).snapshot(
|
|
737
|
+
scheduler: {
|
|
738
|
+
queued: @run_scheduler&.queued_count || 0,
|
|
739
|
+
running: @run_scheduler&.running_count || 0,
|
|
740
|
+
provider_permits_available: provider_permits.available,
|
|
741
|
+
tool_permits_available: tool_permits.available,
|
|
742
|
+
cpu_permits_available: cpu_permits.available,
|
|
743
|
+
locked_resource_count: resource_lock_manager.locked_keys.length
|
|
744
|
+
},
|
|
745
|
+
limits: @scheduler_options.slice(
|
|
746
|
+
:worker_limit, :provider_limit, :tool_limit, :cpu_limit, :max_active_runs,
|
|
747
|
+
:max_active_per_po, :max_queued_per_thread, :max_delegation_depth, :max_descendants,
|
|
748
|
+
:run_event_retention, :workspace_event_retention
|
|
749
|
+
)
|
|
750
|
+
)
|
|
751
|
+
end
|
|
752
|
+
|
|
753
|
+
def enforce_retention
|
|
754
|
+
Operations::Retention.new(@session_store).prune_events!(
|
|
755
|
+
run_event_limit: @scheduler_options.fetch(:run_event_retention),
|
|
756
|
+
workspace_event_limit: @scheduler_options.fetch(:workspace_event_retention)
|
|
757
|
+
)
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
def create_runtime_thread(workspace_session_id:, po_name:, thread_type: "human", name: nil,
|
|
761
|
+
parent_thread_id: nil, parent_run_id: nil)
|
|
762
|
+
workspace_id = resolve_workspace_id(workspace_session_id, initiated_by: "human")
|
|
763
|
+
po = @registry.get(po_name)
|
|
764
|
+
raise Error, "Unknown prompt object: #{po_name}" unless po.is_a?(PromptObject)
|
|
765
|
+
|
|
766
|
+
thread = thread_repository.create(
|
|
767
|
+
workspace_session_id: workspace_id,
|
|
768
|
+
po_name: po_name,
|
|
769
|
+
thread_type: thread_type,
|
|
770
|
+
name: name,
|
|
771
|
+
parent_thread_id: parent_thread_id,
|
|
772
|
+
parent_run_id: parent_run_id,
|
|
773
|
+
retention_policy: thread_type == "autonomous" ? "summary" : "full"
|
|
774
|
+
)
|
|
775
|
+
broadcast_workspace_snapshot(workspace_id)
|
|
776
|
+
broadcast(**thread_snapshot_envelope(thread))
|
|
777
|
+
thread
|
|
778
|
+
end
|
|
779
|
+
|
|
780
|
+
def rename_runtime_thread(thread_id, name:)
|
|
781
|
+
thread = required_runtime_thread(thread_id)
|
|
782
|
+
renamed = thread_repository.rename(thread.id, name)
|
|
783
|
+
broadcast_workspace_snapshot(thread.workspace_session_id)
|
|
784
|
+
renamed
|
|
785
|
+
end
|
|
786
|
+
|
|
787
|
+
def archive_runtime_thread(thread_id)
|
|
788
|
+
thread = required_runtime_thread(thread_id)
|
|
789
|
+
archived = thread_repository.archive(thread.id)
|
|
790
|
+
broadcast_workspace_snapshot(thread.workspace_session_id)
|
|
791
|
+
archived
|
|
792
|
+
end
|
|
793
|
+
|
|
794
|
+
def runtime_snapshot_envelopes(workspace_session_id: nil)
|
|
795
|
+
workspace_id = resolve_workspace_id(workspace_session_id, initiated_by: "reconnect")
|
|
796
|
+
threads = thread_repository.list(workspace_session_id: workspace_id, include_archived: true)
|
|
797
|
+
active_runs = run_repository.list(
|
|
798
|
+
workspace_session_id: workspace_id,
|
|
799
|
+
statuses: %w[queued running waiting],
|
|
800
|
+
limit: 500
|
|
801
|
+
)
|
|
802
|
+
events = active_runs.flat_map do |run|
|
|
803
|
+
event_repository.run_events(run_id: run.id).map do |event|
|
|
804
|
+
{
|
|
805
|
+
version: Execution::Contracts::PROTOCOL_VERSION,
|
|
806
|
+
type: "run_event",
|
|
807
|
+
payload: serialize_runtime_value(event.to_h)
|
|
808
|
+
}
|
|
809
|
+
end
|
|
810
|
+
end
|
|
811
|
+
[workspace_snapshot_envelope(workspace_id)] +
|
|
812
|
+
threads.map { |thread| thread_snapshot_envelope(thread) } +
|
|
813
|
+
events +
|
|
814
|
+
[env_data_snapshot_envelope(workspace_id)]
|
|
815
|
+
end
|
|
816
|
+
|
|
817
|
+
def publish_workspace_snapshot(workspace_session_id)
|
|
818
|
+
broadcast_workspace_snapshot(workspace_session_id)
|
|
819
|
+
end
|
|
820
|
+
|
|
821
|
+
def publish_thread_snapshot(thread_id, po_name: nil)
|
|
822
|
+
thread = thread_repository.get(thread_id)
|
|
823
|
+
raise Error, "Unknown thread: #{thread_id}" unless thread
|
|
824
|
+
|
|
825
|
+
broadcast_thread_snapshot(thread, po_name || thread.po_name)
|
|
826
|
+
end
|
|
827
|
+
|
|
828
|
+
# Persist an artifact before exposing it to clients. The artifact repository
|
|
829
|
+
# is authoritative for identity, provenance, and revision history.
|
|
830
|
+
def persist_artifact(title:, html:, mode:, context:)
|
|
831
|
+
workspace_id = artifact_workspace_id(context)
|
|
832
|
+
owner_po = context.calling_po || (context.respond_to?(:target_po) ? context.target_po : nil) || "unknown"
|
|
833
|
+
owner_thread_id = artifact_owner_thread_id(context, owner_po)
|
|
834
|
+
producing_run_id = context.respond_to?(:run_id) ? context.run_id : nil
|
|
835
|
+
mutation = artifact_repository.upsert_with_cursor(
|
|
836
|
+
workspace_session_id: workspace_id,
|
|
837
|
+
owner_po: owner_po,
|
|
838
|
+
owner_thread_id: owner_thread_id,
|
|
839
|
+
producing_run_id: producing_run_id,
|
|
840
|
+
title: title,
|
|
841
|
+
html: html,
|
|
842
|
+
mode: mode,
|
|
843
|
+
metadata: {}
|
|
844
|
+
)
|
|
845
|
+
artifact = mutation.artifact
|
|
846
|
+
|
|
847
|
+
broadcast(
|
|
848
|
+
version: Execution::Contracts::PROTOCOL_VERSION,
|
|
849
|
+
type: "artifact_upserted",
|
|
850
|
+
payload: {
|
|
851
|
+
cursor: mutation.cursor,
|
|
852
|
+
artifact: serialize_runtime_value(artifact.to_h)
|
|
853
|
+
}
|
|
854
|
+
)
|
|
855
|
+
if producing_run_id
|
|
856
|
+
publish_run_event(event_repository.append_run_event(
|
|
857
|
+
run_id: producing_run_id,
|
|
858
|
+
kind: "artifact_upserted",
|
|
859
|
+
data: {
|
|
860
|
+
artifact_id: artifact.id,
|
|
861
|
+
title: artifact.title,
|
|
862
|
+
revision: artifact.revision,
|
|
863
|
+
owner_thread_id: artifact.owner_thread_id,
|
|
864
|
+
mode: artifact.mode
|
|
865
|
+
}
|
|
866
|
+
))
|
|
867
|
+
end
|
|
868
|
+
artifact
|
|
869
|
+
end
|
|
870
|
+
|
|
871
|
+
def set_environment_data(key:, short_description:, value:, context:)
|
|
872
|
+
workspace_id = environment_data_workspace_id(context)
|
|
873
|
+
stored_by = context.calling_po || (context.respond_to?(:target_po) ? context.target_po : nil) || "unknown"
|
|
874
|
+
unless workspace_repository.get(workspace_id)
|
|
875
|
+
@session_store.store_env_data(
|
|
876
|
+
root_thread_id: workspace_id,
|
|
877
|
+
key: key,
|
|
878
|
+
short_description: short_description,
|
|
879
|
+
value: value,
|
|
880
|
+
stored_by: stored_by
|
|
881
|
+
)
|
|
882
|
+
notify_env_data_changed(
|
|
883
|
+
action: "set", root_thread_id: workspace_id, key: key, stored_by: stored_by
|
|
884
|
+
)
|
|
885
|
+
return @session_store.get_env_data(root_thread_id: workspace_id, key: key)
|
|
886
|
+
end
|
|
887
|
+
mutation = env_data_repository.set_with_cursor(
|
|
888
|
+
workspace_session_id: workspace_id,
|
|
889
|
+
key: key,
|
|
890
|
+
short_description: short_description,
|
|
891
|
+
value: value,
|
|
892
|
+
stored_by: stored_by
|
|
893
|
+
)
|
|
894
|
+
publish_env_data_snapshot(workspace_id, cursor: mutation.cursor)
|
|
895
|
+
publish_environment_data_run_event(context, action: "set", key: key, stored_by: stored_by)
|
|
896
|
+
notify_env_data_changed(
|
|
897
|
+
action: "set",
|
|
898
|
+
root_thread_id: workspace_id,
|
|
899
|
+
key: key,
|
|
900
|
+
stored_by: stored_by
|
|
901
|
+
)
|
|
902
|
+
mutation.entry
|
|
903
|
+
end
|
|
904
|
+
|
|
905
|
+
def get_environment_data(key:, context:)
|
|
906
|
+
workspace_id = environment_data_workspace_id(context)
|
|
907
|
+
if workspace_repository.get(workspace_id)
|
|
908
|
+
env_data_repository.get(workspace_session_id: workspace_id, key: key)
|
|
909
|
+
else
|
|
910
|
+
@session_store.get_env_data(root_thread_id: workspace_id, key: key)
|
|
911
|
+
end
|
|
912
|
+
end
|
|
913
|
+
|
|
914
|
+
def list_environment_data(context:)
|
|
915
|
+
workspace_id = environment_data_workspace_id(context)
|
|
916
|
+
if workspace_repository.get(workspace_id)
|
|
917
|
+
env_data_repository.list(workspace_session_id: workspace_id)
|
|
918
|
+
else
|
|
919
|
+
@session_store.list_env_data_full(root_thread_id: workspace_id)
|
|
920
|
+
end
|
|
921
|
+
end
|
|
922
|
+
|
|
923
|
+
def delete_environment_data(key:, context:)
|
|
924
|
+
workspace_id = environment_data_workspace_id(context)
|
|
925
|
+
stored_by = context.calling_po || (context.respond_to?(:target_po) ? context.target_po : nil) || "unknown"
|
|
926
|
+
unless workspace_repository.get(workspace_id)
|
|
927
|
+
deleted = @session_store.delete_env_data(root_thread_id: workspace_id, key: key)
|
|
928
|
+
if deleted
|
|
929
|
+
notify_env_data_changed(
|
|
930
|
+
action: "delete", root_thread_id: workspace_id, key: key, stored_by: stored_by
|
|
931
|
+
)
|
|
932
|
+
end
|
|
933
|
+
return deleted
|
|
934
|
+
end
|
|
935
|
+
removal = env_data_repository.delete_with_cursor(
|
|
936
|
+
workspace_session_id: workspace_id,
|
|
937
|
+
key: key,
|
|
938
|
+
stored_by: stored_by
|
|
939
|
+
)
|
|
940
|
+
return false unless removal.deleted
|
|
941
|
+
|
|
942
|
+
publish_env_data_snapshot(workspace_id, cursor: removal.cursor)
|
|
943
|
+
publish_environment_data_run_event(context, action: "delete", key: key, stored_by: stored_by)
|
|
944
|
+
notify_env_data_changed(
|
|
945
|
+
action: "delete",
|
|
946
|
+
root_thread_id: workspace_id,
|
|
947
|
+
key: key,
|
|
948
|
+
stored_by: stored_by
|
|
949
|
+
)
|
|
950
|
+
true
|
|
951
|
+
end
|
|
952
|
+
|
|
953
|
+
def inject_run_message(po:, content:, from_source:, workspace_session_id: nil,
|
|
954
|
+
thread_id: nil, client_request_id: nil, deadline: nil)
|
|
955
|
+
run = enqueue_run(
|
|
956
|
+
target_po: po.name,
|
|
957
|
+
content: content,
|
|
958
|
+
from_source: from_source,
|
|
959
|
+
workspace_session_id: workspace_session_id,
|
|
960
|
+
thread_id: thread_id,
|
|
961
|
+
client_request_id: client_request_id,
|
|
962
|
+
deadline: deadline
|
|
963
|
+
)
|
|
964
|
+
execute_run(run.id)
|
|
965
|
+
end
|
|
966
|
+
|
|
967
|
+
# Whether a PO has received a new human message during this runtime boot.
|
|
968
|
+
# Used by opt-in autonomous schedules that must not preempt initialization.
|
|
969
|
+
def human_message_received?(po_name)
|
|
970
|
+
!!@human_message_received[po_name]
|
|
971
|
+
end
|
|
972
|
+
|
|
973
|
+
# Publish truthful autonomous scheduling state for reconnectable UI
|
|
974
|
+
# countdowns. The scheduler owns these transitions; clients only render the
|
|
975
|
+
# server-provided next_run_at instead of guessing from page-load time.
|
|
976
|
+
def update_autonomous_schedule(po_name, **state)
|
|
977
|
+
payload = @autonomous_schedule_mutex.synchronize do
|
|
978
|
+
current = @autonomous_schedule_states[po_name] || { po_name: po_name }
|
|
979
|
+
@autonomous_schedule_states[po_name] = current.merge(state).freeze
|
|
980
|
+
end
|
|
981
|
+
broadcast(type: "autonomous_schedule", payload: payload)
|
|
982
|
+
payload
|
|
983
|
+
end
|
|
984
|
+
|
|
985
|
+
def autonomous_schedule_states
|
|
986
|
+
@autonomous_schedule_mutex.synchronize do
|
|
987
|
+
@autonomous_schedule_states.transform_values(&:dup)
|
|
988
|
+
end
|
|
989
|
+
end
|
|
990
|
+
|
|
991
|
+
# Broadcast a message to all connected WebSocket clients.
|
|
992
|
+
# No-op if on_broadcast callback isn't set (e.g., in tests or CLI mode).
|
|
993
|
+
# @param message [Hash] WebSocket message to broadcast
|
|
994
|
+
def broadcast(**message)
|
|
995
|
+
@on_broadcast&.call(message)
|
|
996
|
+
rescue => e
|
|
997
|
+
# Don't crash if broadcast fails
|
|
998
|
+
end
|
|
999
|
+
|
|
1000
|
+
def publish_run_event(event)
|
|
1001
|
+
broadcast(
|
|
1002
|
+
version: Execution::Contracts::PROTOCOL_VERSION,
|
|
1003
|
+
type: "run_event",
|
|
1004
|
+
payload: serialize_runtime_value(event.to_h)
|
|
1005
|
+
)
|
|
1006
|
+
end
|
|
1007
|
+
|
|
270
1008
|
private
|
|
271
1009
|
|
|
1010
|
+
def normalize_deadline(deadline)
|
|
1011
|
+
return nil if deadline.nil? || deadline.to_s.empty?
|
|
1012
|
+
return deadline if deadline.is_a?(Time)
|
|
1013
|
+
|
|
1014
|
+
Time.iso8601(deadline.to_s)
|
|
1015
|
+
rescue ArgumentError
|
|
1016
|
+
raise Error, "deadline must be an ISO 8601 timestamp"
|
|
1017
|
+
end
|
|
1018
|
+
|
|
1019
|
+
def run_deadline(run)
|
|
1020
|
+
normalize_deadline(metadata_value(run, :deadline))
|
|
1021
|
+
end
|
|
1022
|
+
|
|
1023
|
+
def workspace_repository
|
|
1024
|
+
@workspace_repository ||= Repositories::WorkspaceSessionRepository.new(@session_store)
|
|
1025
|
+
end
|
|
1026
|
+
|
|
1027
|
+
def thread_repository
|
|
1028
|
+
@thread_repository ||= Repositories::ThreadRepository.new(@session_store)
|
|
1029
|
+
end
|
|
1030
|
+
|
|
1031
|
+
def run_repository
|
|
1032
|
+
@run_repository ||= Repositories::RunRepository.new(@session_store)
|
|
1033
|
+
end
|
|
1034
|
+
|
|
1035
|
+
def definition_repository
|
|
1036
|
+
@definition_repository ||= Repositories::DefinitionRepository.new(@session_store)
|
|
1037
|
+
end
|
|
1038
|
+
|
|
1039
|
+
def runtime_message_repository
|
|
1040
|
+
@runtime_message_repository ||= Repositories::MessageRepository.new(@session_store)
|
|
1041
|
+
end
|
|
1042
|
+
|
|
1043
|
+
def event_repository
|
|
1044
|
+
@event_repository ||= Repositories::EventRepository.new(@session_store)
|
|
1045
|
+
end
|
|
1046
|
+
|
|
1047
|
+
def artifact_repository
|
|
1048
|
+
@artifact_repository ||= Repositories::ArtifactRepository.new(@session_store)
|
|
1049
|
+
end
|
|
1050
|
+
|
|
1051
|
+
def env_data_repository
|
|
1052
|
+
@env_data_repository ||= Repositories::EnvDataRepository.new(@session_store)
|
|
1053
|
+
end
|
|
1054
|
+
|
|
1055
|
+
def artifact_workspace_id(context)
|
|
1056
|
+
context_workspace = context.respond_to?(:workspace_session_id) ? context.workspace_session_id : nil
|
|
1057
|
+
workspace_id = context_workspace || active_session(initiated_by: "artifact")&.dig(:id)
|
|
1058
|
+
raise Error, "No active workspace for artifact" unless workspace_id
|
|
1059
|
+
|
|
1060
|
+
workspace_id
|
|
1061
|
+
end
|
|
1062
|
+
|
|
1063
|
+
def artifact_owner_thread_id(context, owner_po)
|
|
1064
|
+
context_thread = context.respond_to?(:thread_id) ? context.thread_id : nil
|
|
1065
|
+
return context_thread if context_thread
|
|
1066
|
+
|
|
1067
|
+
nil
|
|
1068
|
+
end
|
|
1069
|
+
|
|
1070
|
+
def environment_data_workspace_id(context)
|
|
1071
|
+
context_workspace = context.respond_to?(:workspace_session_id) ? context.workspace_session_id : nil
|
|
1072
|
+
workspace_id = context_workspace || context.env_data_scope
|
|
1073
|
+
raise Error, "No active workspace for environment data" unless workspace_id
|
|
1074
|
+
|
|
1075
|
+
workspace_id
|
|
1076
|
+
end
|
|
1077
|
+
|
|
1078
|
+
def publish_env_data_snapshot(workspace_id, cursor: nil)
|
|
1079
|
+
broadcast(**env_data_snapshot_envelope(workspace_id, cursor: cursor))
|
|
1080
|
+
end
|
|
1081
|
+
|
|
1082
|
+
def publish_environment_data_run_event(context, action:, key:, stored_by:)
|
|
1083
|
+
run_id = context.respond_to?(:run_id) ? context.run_id : nil
|
|
1084
|
+
return unless run_id
|
|
1085
|
+
|
|
1086
|
+
publish_run_event(event_repository.append_run_event(
|
|
1087
|
+
run_id: run_id,
|
|
1088
|
+
kind: "env_data_changed",
|
|
1089
|
+
data: { action: action, key: key, stored_by: stored_by }
|
|
1090
|
+
))
|
|
1091
|
+
end
|
|
1092
|
+
|
|
1093
|
+
def execution_runner
|
|
1094
|
+
@execution_runner ||= Execution::Runner.new(
|
|
1095
|
+
self,
|
|
1096
|
+
event_publisher: method(:publish_run_event),
|
|
1097
|
+
provider_permits: provider_permits,
|
|
1098
|
+
tool_permits: tool_permits,
|
|
1099
|
+
cpu_permits: cpu_permits,
|
|
1100
|
+
resource_lock_manager: resource_lock_manager
|
|
1101
|
+
)
|
|
1102
|
+
end
|
|
1103
|
+
|
|
1104
|
+
def provider_permits
|
|
1105
|
+
@provider_permits ||= Execution::PermitPool.new(@scheduler_options.fetch(:provider_limit))
|
|
1106
|
+
end
|
|
1107
|
+
|
|
1108
|
+
def tool_permits
|
|
1109
|
+
@tool_permits ||= Execution::PermitPool.new(@scheduler_options.fetch(:tool_limit))
|
|
1110
|
+
end
|
|
1111
|
+
|
|
1112
|
+
def cpu_permits
|
|
1113
|
+
@cpu_permits ||= Execution::PermitPool.new(@scheduler_options.fetch(:cpu_limit))
|
|
1114
|
+
end
|
|
1115
|
+
|
|
1116
|
+
def resource_lock_manager
|
|
1117
|
+
@resource_lock_manager ||= Execution::ResourceLockManager.new
|
|
1118
|
+
end
|
|
1119
|
+
|
|
1120
|
+
def run_scheduler
|
|
1121
|
+
@run_scheduler ||= begin
|
|
1122
|
+
Execution::Scheduler.new(
|
|
1123
|
+
worker_limit: @scheduler_options.fetch(:worker_limit),
|
|
1124
|
+
max_active_runs: @scheduler_options.fetch(:max_active_runs),
|
|
1125
|
+
max_active_per_po: @scheduler_options.fetch(:max_active_per_po),
|
|
1126
|
+
max_queued_per_thread: @scheduler_options.fetch(:max_queued_per_thread),
|
|
1127
|
+
execute: method(:execute_scheduled_run),
|
|
1128
|
+
ready: ->(run) { run_repository.thread_head?(run) },
|
|
1129
|
+
cancel_queued: method(:cancel_queued_run)
|
|
1130
|
+
)
|
|
1131
|
+
end
|
|
1132
|
+
end
|
|
1133
|
+
|
|
1134
|
+
def execute_scheduled_run(run, cancellation_token, options)
|
|
1135
|
+
thread = thread_repository.get(run.thread_id)
|
|
1136
|
+
raise Error, "Unknown thread: #{run.thread_id}" unless thread
|
|
1137
|
+
|
|
1138
|
+
broadcast_po_runtime_status(run)
|
|
1139
|
+
execution_runner.execute(
|
|
1140
|
+
run.id,
|
|
1141
|
+
cancellation_token: cancellation_token,
|
|
1142
|
+
tui_mode: options.fetch(:tui_mode, true)
|
|
1143
|
+
)
|
|
1144
|
+
ensure
|
|
1145
|
+
publish_dispatch_completion(run)
|
|
1146
|
+
broadcast_thread_snapshot(thread, run.po_name) if thread && thread.thread_type != "autonomous"
|
|
1147
|
+
broadcast_workspace_snapshot(run.workspace_session_id)
|
|
1148
|
+
broadcast_po_runtime_status(run)
|
|
1149
|
+
end
|
|
1150
|
+
|
|
1151
|
+
def cancel_queued_run(run, reason)
|
|
1152
|
+
cancelled = run_repository.transition(run.id, status: "cancelled", cancellation_reason: reason)
|
|
1153
|
+
publish_run_event(event_repository.append_run_event(
|
|
1154
|
+
run_id: run.id,
|
|
1155
|
+
kind: "run_cancelled",
|
|
1156
|
+
data: { reason: reason }
|
|
1157
|
+
))
|
|
1158
|
+
broadcast_workspace_snapshot(run.workspace_session_id)
|
|
1159
|
+
broadcast_po_runtime_status(cancelled)
|
|
1160
|
+
@run_scheduler&.wake
|
|
1161
|
+
publish_dispatch_completion(cancelled)
|
|
1162
|
+
cancelled
|
|
1163
|
+
end
|
|
1164
|
+
|
|
1165
|
+
def reject_queued_run(run, reason)
|
|
1166
|
+
skipped = run_repository.transition(run.id, status: "skipped", cancellation_reason: reason)
|
|
1167
|
+
publish_run_event(event_repository.append_run_event(
|
|
1168
|
+
run_id: run.id,
|
|
1169
|
+
kind: "run_skipped",
|
|
1170
|
+
data: { reason: reason }
|
|
1171
|
+
))
|
|
1172
|
+
broadcast_workspace_snapshot(run.workspace_session_id)
|
|
1173
|
+
broadcast_po_runtime_status(skipped)
|
|
1174
|
+
@run_scheduler&.wake
|
|
1175
|
+
publish_dispatch_completion(skipped)
|
|
1176
|
+
skipped
|
|
1177
|
+
end
|
|
1178
|
+
|
|
1179
|
+
def recover_interrupted_runs
|
|
1180
|
+
return if @recovery_complete
|
|
1181
|
+
|
|
1182
|
+
workspace_id = active_session(initiated_by: "recovery")[:id]
|
|
1183
|
+
run_repository.list(
|
|
1184
|
+
workspace_session_id: workspace_id,
|
|
1185
|
+
statuses: %w[running waiting],
|
|
1186
|
+
limit: 10_000
|
|
1187
|
+
).each do |run|
|
|
1188
|
+
run_repository.transition(run.id, status: "interrupted")
|
|
1189
|
+
publish_run_event(event_repository.append_run_event(
|
|
1190
|
+
run_id: run.id,
|
|
1191
|
+
kind: "run_interrupted",
|
|
1192
|
+
data: { reason: "process restarted" }
|
|
1193
|
+
))
|
|
1194
|
+
end
|
|
1195
|
+
@recovery_complete = true
|
|
1196
|
+
end
|
|
1197
|
+
|
|
1198
|
+
def broadcast_po_runtime_status(run)
|
|
1199
|
+
active = run_repository.active_count(
|
|
1200
|
+
workspace_session_id: run.workspace_session_id,
|
|
1201
|
+
po_name: run.po_name
|
|
1202
|
+
)
|
|
1203
|
+
broadcast(
|
|
1204
|
+
type: "po_state",
|
|
1205
|
+
payload: { name: run.po_name, state: { status: active.positive? ? "thinking" : "idle" } }
|
|
1206
|
+
)
|
|
1207
|
+
end
|
|
1208
|
+
|
|
1209
|
+
def validate_dispatch_target!(caller_name, target_name)
|
|
1210
|
+
target = registry.get(target_name)
|
|
1211
|
+
raise Error, "Unknown dispatch target: #{target_name}" unless target.is_a?(PromptObject)
|
|
1212
|
+
|
|
1213
|
+
caller = registry.get(caller_name)
|
|
1214
|
+
allowed = Array(caller&.config&.dig("capabilities"))
|
|
1215
|
+
raise Error, "#{caller_name} cannot dispatch to #{target_name}" unless allowed.include?(target_name)
|
|
1216
|
+
end
|
|
1217
|
+
|
|
1218
|
+
def required_dispatch_thread(thread_id, parent, target_po)
|
|
1219
|
+
thread = thread_repository.get(thread_id)
|
|
1220
|
+
unless thread && thread.workspace_session_id == parent.workspace_session_id && thread.po_name == target_po
|
|
1221
|
+
raise Error, "Thread not found for dispatch target #{target_po}: #{thread_id}"
|
|
1222
|
+
end
|
|
1223
|
+
|
|
1224
|
+
thread
|
|
1225
|
+
end
|
|
1226
|
+
|
|
1227
|
+
def run_depth(run)
|
|
1228
|
+
depth = metadata_value(run, :delegation_depth)
|
|
1229
|
+
return depth.to_i if depth
|
|
1230
|
+
|
|
1231
|
+
current = run
|
|
1232
|
+
count = 0
|
|
1233
|
+
while current.parent_run_id
|
|
1234
|
+
count += 1
|
|
1235
|
+
current = run_repository.get(current.parent_run_id)
|
|
1236
|
+
break unless current
|
|
1237
|
+
end
|
|
1238
|
+
count
|
|
1239
|
+
end
|
|
1240
|
+
|
|
1241
|
+
def authorized_related_run(run_id, context)
|
|
1242
|
+
parent = run_repository.get(context.run_id)
|
|
1243
|
+
run = run_repository.get(run_id)
|
|
1244
|
+
raise Error, "Unknown run: #{run_id}" unless run
|
|
1245
|
+
unless parent && run.workspace_session_id == parent.workspace_session_id && run.root_run_id == parent.root_run_id
|
|
1246
|
+
raise Error, "Run is outside the caller's execution tree: #{run_id}"
|
|
1247
|
+
end
|
|
1248
|
+
|
|
1249
|
+
run
|
|
1250
|
+
end
|
|
1251
|
+
|
|
1252
|
+
def terminal_run_status?(status)
|
|
1253
|
+
%w[completed failed cancelled skipped interrupted].include?(status)
|
|
1254
|
+
end
|
|
1255
|
+
|
|
1256
|
+
def publish_dispatch_completion(run)
|
|
1257
|
+
return unless metadata_value(run, :dispatch_mode) == "dispatch" && run.parent_run_id
|
|
1258
|
+
return unless terminal_run_status?(run_repository.get(run.id)&.status)
|
|
1259
|
+
|
|
1260
|
+
existing = event_repository.run_events(run_id: run.parent_run_id).any? do |event|
|
|
1261
|
+
event.kind == "delegation_completed" && event.data[:child_run_id] == run.id
|
|
1262
|
+
end
|
|
1263
|
+
return if existing
|
|
1264
|
+
|
|
1265
|
+
current = run_repository.get(run.id)
|
|
1266
|
+
publish_run_event(event_repository.append_run_event(
|
|
1267
|
+
run_id: run.parent_run_id,
|
|
1268
|
+
kind: "delegation_completed",
|
|
1269
|
+
data: {
|
|
1270
|
+
child_run_id: run.id,
|
|
1271
|
+
child_thread_id: run.thread_id,
|
|
1272
|
+
target_po: run.po_name,
|
|
1273
|
+
mode: "dispatch",
|
|
1274
|
+
status: current.status
|
|
1275
|
+
}
|
|
1276
|
+
))
|
|
1277
|
+
enqueue_dispatch_followup(current)
|
|
1278
|
+
end
|
|
1279
|
+
|
|
1280
|
+
def metadata_value(run, key)
|
|
1281
|
+
return nil unless run
|
|
1282
|
+
|
|
1283
|
+
run.metadata.key?(key) ? run.metadata[key] : run.metadata[key.to_s]
|
|
1284
|
+
end
|
|
1285
|
+
|
|
1286
|
+
def enqueue_dispatch_followup(child)
|
|
1287
|
+
instruction = metadata_value(child, :on_complete)
|
|
1288
|
+
return if instruction.to_s.empty?
|
|
1289
|
+
|
|
1290
|
+
parent = run_repository.get(child.parent_run_id)
|
|
1291
|
+
return unless parent
|
|
1292
|
+
return if %w[cancelled interrupted].include?(parent.status)
|
|
1293
|
+
|
|
1294
|
+
target = registry.get(parent.po_name)
|
|
1295
|
+
return unless target.is_a?(PromptObject)
|
|
1296
|
+
|
|
1297
|
+
definition = execution_definition_for(target)
|
|
1298
|
+
followup = run_repository.enqueue(
|
|
1299
|
+
workspace_session_id: parent.workspace_session_id,
|
|
1300
|
+
thread_id: parent.thread_id,
|
|
1301
|
+
po_name: parent.po_name,
|
|
1302
|
+
input_content: "#{instruction}\n\nDispatched run #{child.id} finished with status #{child.status}.",
|
|
1303
|
+
definition_version: definition.version,
|
|
1304
|
+
provider: definition.provider,
|
|
1305
|
+
model: definition.model,
|
|
1306
|
+
capability_policy_version: definition.capability_policy_version,
|
|
1307
|
+
source_type: "delegation",
|
|
1308
|
+
source_name: child.po_name,
|
|
1309
|
+
client_request_id: "dispatch_completion:#{child.id}",
|
|
1310
|
+
parent_run_id: child.id,
|
|
1311
|
+
priority: 80,
|
|
1312
|
+
metadata: { completion_trigger_for: child.id }
|
|
1313
|
+
)
|
|
1314
|
+
definition_repository.attach(run_id: followup.id, definition: definition) unless definition_repository.get(followup.id)
|
|
1315
|
+
return unless followup.status == "queued" && event_repository.run_events(run_id: followup.id).empty?
|
|
1316
|
+
|
|
1317
|
+
publish_workspace_snapshot(parent.workspace_session_id)
|
|
1318
|
+
publish_run_event(event_repository.append_run_event(
|
|
1319
|
+
run_id: followup.id,
|
|
1320
|
+
kind: "run_queued",
|
|
1321
|
+
data: { source_type: followup.source_type, turn_ordinal: followup.turn_ordinal, trigger_run_id: child.id }
|
|
1322
|
+
))
|
|
1323
|
+
schedule_run(followup.id)
|
|
1324
|
+
end
|
|
1325
|
+
|
|
1326
|
+
def resolve_workspace_id(requested_id, initiated_by:)
|
|
1327
|
+
active_id = active_session(initiated_by: initiated_by)[:id]
|
|
1328
|
+
if requested_id && requested_id != active_id
|
|
1329
|
+
raise Error, "Workspace session is not active: #{requested_id}"
|
|
1330
|
+
end
|
|
1331
|
+
|
|
1332
|
+
active_id
|
|
1333
|
+
end
|
|
1334
|
+
|
|
1335
|
+
def required_runtime_thread(thread_id)
|
|
1336
|
+
thread = thread_repository.get(thread_id)
|
|
1337
|
+
raise Error, "Unknown thread: #{thread_id}" unless thread
|
|
1338
|
+
|
|
1339
|
+
resolve_workspace_id(thread.workspace_session_id, initiated_by: "human")
|
|
1340
|
+
thread
|
|
1341
|
+
end
|
|
1342
|
+
|
|
1343
|
+
def resolve_run_thread(workspace_id:, po:, from_source:, thread_id:)
|
|
1344
|
+
if thread_id
|
|
1345
|
+
thread = thread_repository.get(thread_id)
|
|
1346
|
+
unless thread && thread.workspace_session_id == workspace_id && thread.po_name == po.name
|
|
1347
|
+
raise Error, "Thread not found for #{po.name}: #{thread_id}"
|
|
1348
|
+
end
|
|
1349
|
+
return thread
|
|
1350
|
+
end
|
|
1351
|
+
|
|
1352
|
+
thread_type = thread_type_for(from_source)
|
|
1353
|
+
existing = thread_repository.list(workspace_session_id: workspace_id, po_name: po.name)
|
|
1354
|
+
.find { |thread| thread.thread_type == thread_type }
|
|
1355
|
+
existing || thread_repository.create(
|
|
1356
|
+
workspace_session_id: workspace_id,
|
|
1357
|
+
po_name: po.name,
|
|
1358
|
+
thread_type: thread_type,
|
|
1359
|
+
retention_policy: thread_type == "autonomous" ? "summary" : "full"
|
|
1360
|
+
)
|
|
1361
|
+
end
|
|
1362
|
+
|
|
1363
|
+
def thread_type_for(from_source)
|
|
1364
|
+
return "human" if from_source == "human"
|
|
1365
|
+
return "autonomous" if from_source.start_with?("timer:")
|
|
1366
|
+
return "artifact" if from_source.start_with?("artifact:")
|
|
1367
|
+
return "service" if from_source.start_with?("service:")
|
|
1368
|
+
|
|
1369
|
+
"api"
|
|
1370
|
+
end
|
|
1371
|
+
|
|
1372
|
+
def source_type_for(from_source)
|
|
1373
|
+
thread_type_for(from_source)
|
|
1374
|
+
end
|
|
1375
|
+
|
|
1376
|
+
def priority_for(from_source)
|
|
1377
|
+
case thread_type_for(from_source)
|
|
1378
|
+
when "human" then 100
|
|
1379
|
+
when "artifact" then 90
|
|
1380
|
+
when "delegation" then 80
|
|
1381
|
+
when "api" then 60
|
|
1382
|
+
when "service" then 40
|
|
1383
|
+
else 10
|
|
1384
|
+
end
|
|
1385
|
+
end
|
|
1386
|
+
|
|
1387
|
+
def broadcast_thread_snapshot(thread, _po_name)
|
|
1388
|
+
messages = runtime_message_repository.list(thread_id: thread.id, limit: 10_000)
|
|
1389
|
+
broadcast(**thread_snapshot_envelope(thread, messages: messages))
|
|
1390
|
+
end
|
|
1391
|
+
|
|
1392
|
+
def broadcast_workspace_snapshot(workspace_id)
|
|
1393
|
+
broadcast(**workspace_snapshot_envelope(workspace_id))
|
|
1394
|
+
end
|
|
1395
|
+
|
|
1396
|
+
def workspace_snapshot_envelope(workspace_id)
|
|
1397
|
+
workspace = workspace_repository.get(workspace_id)
|
|
1398
|
+
raise Error, "Unknown workspace session: #{workspace_id}" unless workspace
|
|
1399
|
+
|
|
1400
|
+
{
|
|
1401
|
+
version: Execution::Contracts::PROTOCOL_VERSION,
|
|
1402
|
+
type: "workspace_snapshot",
|
|
1403
|
+
payload: {
|
|
1404
|
+
cursor: event_repository.current_cursor(workspace_session_id: workspace_id),
|
|
1405
|
+
workspace: serialize_runtime_value(workspace.to_h),
|
|
1406
|
+
threads: thread_repository.list(
|
|
1407
|
+
workspace_session_id: workspace_id,
|
|
1408
|
+
include_archived: true
|
|
1409
|
+
).map { |thread| serialize_runtime_value(thread.to_h) },
|
|
1410
|
+
runs: run_repository.list(
|
|
1411
|
+
workspace_session_id: workspace_id,
|
|
1412
|
+
limit: 500
|
|
1413
|
+
).map { |run| serialize_runtime_value(run.to_h) },
|
|
1414
|
+
artifacts: artifact_repository.list(
|
|
1415
|
+
workspace_session_id: workspace_id
|
|
1416
|
+
).map { |artifact| serialize_runtime_value(artifact.to_h) }
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
end
|
|
1420
|
+
|
|
1421
|
+
def env_data_snapshot_envelope(workspace_id, cursor: nil)
|
|
1422
|
+
{
|
|
1423
|
+
version: Execution::Contracts::PROTOCOL_VERSION,
|
|
1424
|
+
type: "env_data_snapshot",
|
|
1425
|
+
payload: {
|
|
1426
|
+
workspace_session_id: workspace_id,
|
|
1427
|
+
cursor: cursor || event_repository.current_cursor(workspace_session_id: workspace_id),
|
|
1428
|
+
entries: env_data_repository.list(
|
|
1429
|
+
workspace_session_id: workspace_id
|
|
1430
|
+
).map { |entry| serialize_runtime_value(entry) }
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
end
|
|
1434
|
+
|
|
1435
|
+
def thread_snapshot_envelope(thread, messages: nil)
|
|
1436
|
+
messages ||= runtime_message_repository.list(thread_id: thread.id, limit: 10_000)
|
|
1437
|
+
{
|
|
1438
|
+
version: Execution::Contracts::PROTOCOL_VERSION,
|
|
1439
|
+
type: "thread_snapshot",
|
|
1440
|
+
payload: {
|
|
1441
|
+
cursor: event_repository.current_cursor(workspace_session_id: thread.workspace_session_id),
|
|
1442
|
+
thread: serialize_runtime_value(thread.to_h),
|
|
1443
|
+
messages: messages.map { |message| serialize_runtime_message(message) }
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
end
|
|
1447
|
+
|
|
1448
|
+
def serialize_runtime_value(value)
|
|
1449
|
+
case value
|
|
1450
|
+
when Time then value.iso8601
|
|
1451
|
+
when Hash then value.to_h { |key, nested| [key, serialize_runtime_value(nested)] }
|
|
1452
|
+
when Array then value.map { |nested| serialize_runtime_value(nested) }
|
|
1453
|
+
else value
|
|
1454
|
+
end
|
|
1455
|
+
end
|
|
1456
|
+
|
|
1457
|
+
def serialize_runtime_message(message)
|
|
1458
|
+
serialize_runtime_value(message).merge(
|
|
1459
|
+
id: message[:message_uid] || message[:id].to_s,
|
|
1460
|
+
cursor_id: message[:id]
|
|
1461
|
+
)
|
|
1462
|
+
end
|
|
1463
|
+
|
|
1464
|
+
# Find the most recent environment session or create one. Looks only at
|
|
1465
|
+
# environment-level sessions (po_name IS NULL), not per-PO sessions, so the
|
|
1466
|
+
# active session is a real shared-context container.
|
|
1467
|
+
def find_or_create_environment_session(initiated_by: "human")
|
|
1468
|
+
return nil unless @session_store
|
|
1469
|
+
|
|
1470
|
+
existing = @session_store.list_environment_sessions(limit: 1)
|
|
1471
|
+
return existing.first[:id] if existing.any?
|
|
1472
|
+
|
|
1473
|
+
create_environment_session(initiated_by: initiated_by)[:id]
|
|
1474
|
+
end
|
|
1475
|
+
|
|
1476
|
+
# Load vendored gems and validate declared dependencies.
|
|
1477
|
+
# Templates can vendor gems in vendor/<gem-name>/lib/ to avoid
|
|
1478
|
+
# requiring users to install gems or fight Bundler isolation.
|
|
1479
|
+
# Falls back to system gem activation for non-vendored deps.
|
|
1480
|
+
def validate_dependencies
|
|
1481
|
+
load_vendored_gems
|
|
1482
|
+
|
|
1483
|
+
gems = @manifest.dependencies["gems"]
|
|
1484
|
+
return unless gems.is_a?(Array) && gems.any?
|
|
1485
|
+
|
|
1486
|
+
missing = gems.filter_map do |gem_info|
|
|
1487
|
+
name = gem_info["name"]
|
|
1488
|
+
# Check if the gem is already loadable (vendored or system)
|
|
1489
|
+
begin
|
|
1490
|
+
require name
|
|
1491
|
+
nil
|
|
1492
|
+
rescue LoadError
|
|
1493
|
+
gem_info
|
|
1494
|
+
end
|
|
1495
|
+
end
|
|
1496
|
+
|
|
1497
|
+
return if missing.empty?
|
|
1498
|
+
|
|
1499
|
+
install_cmds = missing.map { |g| " gem install #{g['name']} # #{g['purpose']}" }
|
|
1500
|
+
warn "Warning: Missing dependencies for environment '#{@manifest.name}':"
|
|
1501
|
+
install_cmds.each { |cmd| warn cmd }
|
|
1502
|
+
warn "\nSome primitives may not load. Install the gems and restart."
|
|
1503
|
+
end
|
|
1504
|
+
|
|
1505
|
+
# Add vendor/<gem>/lib directories to $LOAD_PATH so vendored
|
|
1506
|
+
# gems are requireable without system installation.
|
|
1507
|
+
def load_vendored_gems
|
|
1508
|
+
return unless @env_path
|
|
1509
|
+
|
|
1510
|
+
vendor_dir = File.join(@env_path, "vendor")
|
|
1511
|
+
return unless Dir.exist?(vendor_dir)
|
|
1512
|
+
|
|
1513
|
+
Dir.glob(File.join(vendor_dir, "*", "lib")).sort.each do |lib_path|
|
|
1514
|
+
$LOAD_PATH.unshift(lib_path) unless $LOAD_PATH.include?(lib_path)
|
|
1515
|
+
end
|
|
1516
|
+
end
|
|
1517
|
+
|
|
272
1518
|
# Register built-in primitive capabilities.
|
|
273
1519
|
def register_primitives
|
|
274
1520
|
@registry.register(Primitives::ReadFile.new)
|
|
275
1521
|
@registry.register(Primitives::ListFiles.new)
|
|
276
1522
|
@registry.register(Primitives::WriteFile.new)
|
|
277
1523
|
@registry.register(Primitives::HttpGet.new)
|
|
1524
|
+
@registry.register(Primitives::Think.new)
|
|
278
1525
|
|
|
279
1526
|
# Load custom primitives from environment's primitives directory
|
|
280
1527
|
load_custom_primitives
|
|
281
1528
|
end
|
|
282
1529
|
|
|
283
1530
|
# Load custom primitives from the environment's primitives/ directory.
|
|
284
|
-
#
|
|
285
|
-
#
|
|
1531
|
+
# Derives the class name from the filename (snake_case -> CamelCase), the
|
|
1532
|
+
# same convention create_capability uses when it generates a primitive.
|
|
1533
|
+
# Warns on errors rather than crashing so a broken primitive
|
|
286
1534
|
# file doesn't prevent the environment from starting (a PO can fix it).
|
|
287
1535
|
def load_custom_primitives
|
|
288
1536
|
return unless @primitives_dir && Dir.exist?(@primitives_dir)
|
|
@@ -294,7 +1542,7 @@ module PromptObjects
|
|
|
294
1542
|
load(path)
|
|
295
1543
|
klass = PromptObjects::Primitives.const_get(class_name)
|
|
296
1544
|
@registry.register(klass.new)
|
|
297
|
-
rescue SyntaxError, StandardError => e
|
|
1545
|
+
rescue SyntaxError, LoadError, StandardError => e
|
|
298
1546
|
warn "Warning: Failed to load custom primitive #{path}: #{e.message}"
|
|
299
1547
|
end
|
|
300
1548
|
end
|
|
@@ -302,20 +1550,104 @@ module PromptObjects
|
|
|
302
1550
|
|
|
303
1551
|
# Register universal capabilities (available to all prompt objects).
|
|
304
1552
|
def register_universal_capabilities
|
|
305
|
-
@registry.register(Universal::AskHuman.new)
|
|
306
|
-
@registry.register(Universal::Think.new)
|
|
307
1553
|
@registry.register(Universal::CreateCapability.new)
|
|
308
1554
|
@registry.register(Universal::AddCapability.new)
|
|
309
1555
|
@registry.register(Universal::RemoveCapability.new)
|
|
310
1556
|
@registry.register(Universal::ListCapabilities.new)
|
|
311
|
-
@registry.register(Universal::ListPrimitives.new)
|
|
312
|
-
@registry.register(Universal::AddPrimitive.new)
|
|
313
|
-
@registry.register(Universal::CreatePrimitive.new)
|
|
314
1557
|
@registry.register(Universal::DeletePrimitive.new)
|
|
315
1558
|
@registry.register(Universal::VerifyPrimitive.new)
|
|
316
1559
|
@registry.register(Universal::ModifyPrimitive.new)
|
|
317
1560
|
@registry.register(Universal::RequestPrimitive.new)
|
|
318
1561
|
@registry.register(Universal::ModifyPrompt.new)
|
|
1562
|
+
@registry.register(Universal::SetEnvData.new)
|
|
1563
|
+
@registry.register(Universal::GetEnvData.new)
|
|
1564
|
+
@registry.register(Universal::ListEnvData.new)
|
|
1565
|
+
@registry.register(Universal::DeleteEnvData.new)
|
|
1566
|
+
@registry.register(Universal::RenderArtifact.new)
|
|
1567
|
+
@registry.register(Universal::DispatchPO.new)
|
|
1568
|
+
@registry.register(Universal::GetRunStatus.new)
|
|
1569
|
+
@registry.register(Universal::JoinRuns.new)
|
|
1570
|
+
end
|
|
1571
|
+
|
|
1572
|
+
# Load custom services from the environment's services/ directory.
|
|
1573
|
+
# Services are background processes with a tick loop, following
|
|
1574
|
+
# the same loading pattern as custom primitives.
|
|
1575
|
+
def load_custom_services
|
|
1576
|
+
return unless @services_dir && Dir.exist?(@services_dir)
|
|
1577
|
+
|
|
1578
|
+
Dir.glob(File.join(@services_dir, "*.rb")).sort.each do |path|
|
|
1579
|
+
begin
|
|
1580
|
+
filename = File.basename(path, ".rb")
|
|
1581
|
+
class_name = filename.split("_").map(&:capitalize).join
|
|
1582
|
+
load(path)
|
|
1583
|
+
klass = PromptObjects::Services.const_get(class_name)
|
|
1584
|
+
service = klass.new(runtime: self)
|
|
1585
|
+
@services << service
|
|
1586
|
+
rescue SyntaxError, LoadError, StandardError => e
|
|
1587
|
+
warn "Warning: Failed to load service #{path}: #{e.message}"
|
|
1588
|
+
end
|
|
1589
|
+
end
|
|
1590
|
+
end
|
|
1591
|
+
|
|
1592
|
+
public
|
|
1593
|
+
|
|
1594
|
+
# Start all loaded services by calling their start method.
|
|
1595
|
+
# Does NOT schedule tick loops — that's the server's responsibility
|
|
1596
|
+
# using Async tasks (see Server.start).
|
|
1597
|
+
def start_services
|
|
1598
|
+
@services.each do |service|
|
|
1599
|
+
begin
|
|
1600
|
+
service.start
|
|
1601
|
+
rescue => e
|
|
1602
|
+
warn "Warning: Failed to start service #{service.name}: #{e.message}"
|
|
1603
|
+
end
|
|
1604
|
+
end
|
|
1605
|
+
end
|
|
1606
|
+
|
|
1607
|
+
# Stop all running services.
|
|
1608
|
+
def stop_services
|
|
1609
|
+
@services.each do |service|
|
|
1610
|
+
service.stop
|
|
1611
|
+
rescue => e
|
|
1612
|
+
warn "Warning: Failed to stop service #{service.name}: #{e.message}"
|
|
1613
|
+
end
|
|
1614
|
+
end
|
|
1615
|
+
|
|
1616
|
+
# Load connectors from the environment's connectors/ directory. A connector
|
|
1617
|
+
# is a Connectors::Base subclass that bridges an external system (Discord,
|
|
1618
|
+
# Slack, a webhook listener, …) to POs by calling inject_message. Loaded
|
|
1619
|
+
# like services; started/stopped with the server.
|
|
1620
|
+
def load_custom_connectors
|
|
1621
|
+
return unless @connectors_dir && Dir.exist?(@connectors_dir)
|
|
1622
|
+
|
|
1623
|
+
Dir.glob(File.join(@connectors_dir, "*.rb")).sort.each do |path|
|
|
1624
|
+
begin
|
|
1625
|
+
class_name = File.basename(path, ".rb").split("_").map(&:capitalize).join
|
|
1626
|
+
load(path)
|
|
1627
|
+
klass = PromptObjects::Connectors.const_get(class_name)
|
|
1628
|
+
@connectors << klass.new(runtime: self)
|
|
1629
|
+
rescue SyntaxError, LoadError, StandardError => e
|
|
1630
|
+
warn "Warning: Failed to load connector #{path}: #{e.message}"
|
|
1631
|
+
end
|
|
1632
|
+
end
|
|
1633
|
+
end
|
|
1634
|
+
|
|
1635
|
+
# Start all loaded connectors. Each connector manages its own loop/thread.
|
|
1636
|
+
def start_connectors
|
|
1637
|
+
@connectors.each do |connector|
|
|
1638
|
+
connector.start
|
|
1639
|
+
rescue => e
|
|
1640
|
+
warn "Warning: Failed to start connector #{connector.source_name}: #{e.message}"
|
|
1641
|
+
end
|
|
1642
|
+
end
|
|
1643
|
+
|
|
1644
|
+
# Stop all running connectors.
|
|
1645
|
+
def stop_connectors
|
|
1646
|
+
@connectors.each do |connector|
|
|
1647
|
+
connector.stop
|
|
1648
|
+
rescue => e
|
|
1649
|
+
warn "Warning: Failed to stop connector #{connector.source_name}: #{e.message}"
|
|
1650
|
+
end
|
|
319
1651
|
end
|
|
320
1652
|
end
|
|
321
1653
|
|