prompt_objects 0.4.0 → 0.6.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 (80) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +33 -0
  3. data/CLAUDE.md +113 -44
  4. data/README.md +140 -14
  5. data/frontend/index.html +5 -1
  6. data/frontend/src/App.tsx +72 -79
  7. data/frontend/src/canvas/CanvasView.tsx +5 -5
  8. data/frontend/src/canvas/constants.ts +31 -31
  9. data/frontend/src/canvas/inspector/InspectorPanel.tsx +4 -4
  10. data/frontend/src/canvas/inspector/POInspector.tsx +35 -35
  11. data/frontend/src/canvas/inspector/ToolCallInspector.tsx +13 -13
  12. data/frontend/src/canvas/nodes/PONode.ts +2 -2
  13. data/frontend/src/components/ContextMenu.tsx +5 -4
  14. data/frontend/src/components/EnvDataPane.tsx +69 -0
  15. data/frontend/src/components/Inspector.tsx +263 -0
  16. data/frontend/src/components/MarkdownMessage.tsx +22 -20
  17. data/frontend/src/components/MethodList.tsx +90 -0
  18. data/frontend/src/components/ModelSelector.tsx +13 -14
  19. data/frontend/src/components/NotificationPanel.tsx +29 -33
  20. data/frontend/src/components/ObjectList.tsx +78 -0
  21. data/frontend/src/components/PaneSlot.tsx +30 -0
  22. data/frontend/src/components/SourcePane.tsx +202 -0
  23. data/frontend/src/components/SystemBar.tsx +74 -0
  24. data/frontend/src/components/Transcript.tsx +76 -0
  25. data/frontend/src/components/UsagePanel.tsx +27 -27
  26. data/frontend/src/components/Workspace.tsx +260 -0
  27. data/frontend/src/components/index.ts +10 -9
  28. data/frontend/src/hooks/useResize.ts +55 -0
  29. data/frontend/src/hooks/useWebSocket.ts +70 -0
  30. data/frontend/src/index.css +27 -10
  31. data/frontend/src/store/index.ts +36 -0
  32. data/frontend/src/types/index.ts +13 -0
  33. data/frontend/tailwind.config.js +28 -9
  34. data/lib/prompt_objects/capability.rb +23 -1
  35. data/lib/prompt_objects/connectors/mcp.rb +2 -16
  36. data/lib/prompt_objects/environment.rb +15 -0
  37. data/lib/prompt_objects/llm/openai_adapter.rb +22 -0
  38. data/lib/prompt_objects/mcp/tools/inspect_po.rb +1 -31
  39. data/lib/prompt_objects/mcp/tools/list_prompt_objects.rb +1 -6
  40. data/lib/prompt_objects/prompt_object.rb +239 -7
  41. data/lib/prompt_objects/server/api/routes.rb +16 -48
  42. data/lib/prompt_objects/server/app.rb +14 -0
  43. data/lib/prompt_objects/server/public/assets/{index-xvyeb-5Z.js → index-DEPawnfZ.js} +206 -206
  44. data/lib/prompt_objects/server/public/assets/index-oMrRce1m.css +1 -0
  45. data/lib/prompt_objects/server/public/index.html +7 -3
  46. data/lib/prompt_objects/server/websocket_handler.rb +41 -98
  47. data/lib/prompt_objects/server.rb +6 -62
  48. data/lib/prompt_objects/session/store.rb +176 -4
  49. data/lib/prompt_objects/universal/delete_env_data.rb +70 -0
  50. data/lib/prompt_objects/universal/get_env_data.rb +64 -0
  51. data/lib/prompt_objects/universal/list_env_data.rb +61 -0
  52. data/lib/prompt_objects/universal/store_env_data.rb +87 -0
  53. data/lib/prompt_objects/universal/update_env_data.rb +88 -0
  54. data/lib/prompt_objects.rb +6 -1
  55. data/prompt_objects.gemspec +1 -1
  56. data/templates/arc-agi-1/objects/observer.md +4 -0
  57. data/templates/arc-agi-1/objects/solver.md +10 -1
  58. data/templates/arc-agi-1/objects/verifier.md +4 -0
  59. data/templates/arc-agi-1/primitives/find_objects.rb +1 -1
  60. data/templates/arc-agi-1/primitives/grid_diff.rb +2 -2
  61. data/templates/arc-agi-1/primitives/grid_info.rb +1 -1
  62. data/templates/arc-agi-1/primitives/grid_transform.rb +1 -1
  63. data/templates/arc-agi-1/primitives/render_grid.rb +1 -0
  64. data/templates/arc-agi-1/primitives/test_solution.rb +3 -0
  65. data/tools/thread-explorer.html +27 -0
  66. metadata +18 -16
  67. data/Gemfile.lock +0 -233
  68. data/IMPLEMENTATION_PLAN.md +0 -1073
  69. data/design-doc-v2.md +0 -1232
  70. data/frontend/src/components/CapabilitiesPanel.tsx +0 -141
  71. data/frontend/src/components/ChatPanel.tsx +0 -296
  72. data/frontend/src/components/Dashboard.tsx +0 -83
  73. data/frontend/src/components/Header.tsx +0 -153
  74. data/frontend/src/components/MessageBus.tsx +0 -56
  75. data/frontend/src/components/POCard.tsx +0 -56
  76. data/frontend/src/components/PODetail.tsx +0 -124
  77. data/frontend/src/components/PromptPanel.tsx +0 -156
  78. data/frontend/src/components/SessionsPanel.tsx +0 -174
  79. data/frontend/src/components/ThreadsSidebar.tsx +0 -163
  80. data/lib/prompt_objects/server/public/assets/index-6y64NXFy.css +0 -1
@@ -7,6 +7,7 @@ import type {
7
7
  Environment,
8
8
  Message,
9
9
  LLMConfig,
10
+ EnvDataEntry,
10
11
  } from '../types'
11
12
 
12
13
  interface Store {
@@ -41,6 +42,8 @@ interface Store {
41
42
  addBusMessage: (message: BusMessage) => void
42
43
  busOpen: boolean
43
44
  toggleBus: () => void
45
+ topPaneCollapsed: boolean
46
+ toggleTopPane: () => void
44
47
 
45
48
  // Notifications
46
49
  notifications: Notification[]
@@ -66,6 +69,15 @@ interface Store {
66
69
  usageData: Record<string, unknown> | null
67
70
  setUsageData: (data: Record<string, unknown>) => void
68
71
  clearUsageData: () => void
72
+
73
+ // Env Data
74
+ envData: Record<string, EnvDataEntry[]>
75
+ setEnvData: (rootThreadId: string, entries: EnvDataEntry[]) => void
76
+ clearEnvData: (rootThreadId: string) => void
77
+ sessionRootMap: Record<string, string>
78
+ setSessionRoot: (sessionId: string, rootThreadId: string) => void
79
+ envDataPaneCollapsed: boolean
80
+ toggleEnvDataPane: () => void
69
81
  }
70
82
 
71
83
  export const useStore = create<Store>((set) => ({
@@ -203,6 +215,8 @@ export const useStore = create<Store>((set) => ({
203
215
  })),
204
216
  busOpen: false,
205
217
  toggleBus: () => set((s) => ({ busOpen: !s.busOpen })),
218
+ topPaneCollapsed: false,
219
+ toggleTopPane: () => set((s) => ({ topPaneCollapsed: !s.topPaneCollapsed })),
206
220
 
207
221
  // Notifications
208
222
  notifications: [],
@@ -259,6 +273,25 @@ export const useStore = create<Store>((set) => ({
259
273
  usageData: null,
260
274
  setUsageData: (data) => set({ usageData: data }),
261
275
  clearUsageData: () => set({ usageData: null }),
276
+
277
+ // Env Data
278
+ envData: {},
279
+ setEnvData: (rootThreadId, entries) =>
280
+ set((s) => ({
281
+ envData: { ...s.envData, [rootThreadId]: entries },
282
+ })),
283
+ clearEnvData: (rootThreadId) =>
284
+ set((s) => {
285
+ const { [rootThreadId]: _, ...rest } = s.envData
286
+ return { envData: rest }
287
+ }),
288
+ sessionRootMap: {},
289
+ setSessionRoot: (sessionId, rootThreadId) =>
290
+ set((s) => ({
291
+ sessionRootMap: { ...s.sessionRootMap, [sessionId]: rootThreadId },
292
+ })),
293
+ envDataPaneCollapsed: true,
294
+ toggleEnvDataPane: () => set((s) => ({ envDataPaneCollapsed: !s.envDataPaneCollapsed })),
262
295
  }))
263
296
 
264
297
  // Selectors - use useShallow to prevent infinite re-renders with derived arrays
@@ -273,3 +306,6 @@ export const useNotificationCount = () =>
273
306
 
274
307
  export const usePONotifications = (poName: string) =>
275
308
  useStore(useShallow((s) => s.notifications.filter((n) => n.po_name === poName)))
309
+
310
+ export const useEnvData = (rootThreadId: string | undefined) =>
311
+ useStore(useShallow((s) => (rootThreadId ? s.envData[rootThreadId] || [] : [])))
@@ -102,6 +102,15 @@ export interface LLMConfig {
102
102
  providers: LLMProvider[]
103
103
  }
104
104
 
105
+ export interface EnvDataEntry {
106
+ key: string
107
+ short_description: string
108
+ value: unknown
109
+ stored_by: string
110
+ created_at: string
111
+ updated_at: string
112
+ }
113
+
105
114
  // WebSocket message types
106
115
  export type WSMessageType =
107
116
  | 'environment'
@@ -126,6 +135,10 @@ export type WSMessageType =
126
135
  | 'llm_switched'
127
136
  | 'session_usage'
128
137
  | 'thread_export'
138
+ | 'prompt_updated'
139
+ | 'llm_error'
140
+ | 'env_data_changed'
141
+ | 'env_data_list'
129
142
  | 'error'
130
143
  | 'pong'
131
144
 
@@ -7,18 +7,37 @@ export default {
7
7
  theme: {
8
8
  extend: {
9
9
  colors: {
10
- // Custom colors for PromptObjects
11
10
  po: {
12
- bg: '#0f0f1a',
13
- surface: '#1a1a2e',
14
- border: '#2d2d44',
15
- accent: '#7c3aed',
16
- 'accent-hover': '#9061f9',
17
- success: '#22c55e',
18
- warning: '#f59e0b',
19
- error: '#ef4444',
11
+ bg: '#1a1918',
12
+ surface: '#222120',
13
+ 'surface-2': '#2c2a28',
14
+ 'surface-3': '#363432',
15
+ border: '#3d3a37',
16
+ 'border-focus': '#5c5752',
17
+ accent: '#d4952a',
18
+ 'accent-muted': '#9a6d20',
19
+ 'accent-wash': 'rgba(212,149,42,0.08)',
20
+ 'text-primary': '#e8e2da',
21
+ 'text-secondary': '#a8a29a',
22
+ 'text-tertiary': '#78726a',
23
+ 'text-ghost': '#524e48',
24
+ 'status-idle': '#78726a',
25
+ 'status-active': '#d4952a',
26
+ 'status-calling': '#3b9a6e',
27
+ 'status-error': '#c45c4a',
28
+ 'status-delegated': '#5a8fc2',
29
+ success: '#3b9a6e',
30
+ warning: '#d4952a',
31
+ error: '#c45c4a',
20
32
  },
21
33
  },
34
+ fontFamily: {
35
+ ui: ['Geist', 'system-ui', 'sans-serif'],
36
+ mono: ['"Geist Mono"', '"IBM Plex Mono"', 'monospace'],
37
+ },
38
+ fontSize: {
39
+ '2xs': ['11px', { lineHeight: '15px' }],
40
+ },
22
41
  },
23
42
  },
24
43
  plugins: [],
@@ -27,11 +27,33 @@ module PromptObjects
27
27
  function: {
28
28
  name: name,
29
29
  description: description,
30
- parameters: parameters
30
+ parameters: sanitize_schema(parameters)
31
31
  }
32
32
  }
33
33
  end
34
34
 
35
+ private
36
+
37
+ # Ensure array-typed properties have an `items` field.
38
+ # LLM APIs (Gemini, OpenAI, Ollama) reject array schemas without items.
39
+ def sanitize_schema(schema)
40
+ return schema unless schema.is_a?(Hash)
41
+
42
+ schema = schema.dup
43
+
44
+ if schema[:type] == "array" && !schema.key?(:items) && !schema.key?("items")
45
+ schema[:items] = {}
46
+ end
47
+
48
+ if schema[:properties].is_a?(Hash)
49
+ schema[:properties] = schema[:properties].transform_values { |v| sanitize_schema(v) }
50
+ end
51
+
52
+ schema
53
+ end
54
+
55
+ public
56
+
35
57
  # Define the parameters this capability accepts.
36
58
  # Override in subclasses for specific parameter schemas.
37
59
  # @return [Hash] JSON Schema for parameters
@@ -183,12 +183,7 @@ module PromptObjects
183
183
  env = server_context[:env]
184
184
 
185
185
  pos = env.registry.prompt_objects.map do |po|
186
- {
187
- name: po.name,
188
- description: po.description,
189
- state: po.state.to_s,
190
- capabilities: po.config["capabilities"] || []
191
- }
186
+ po.to_summary_hash(registry: env.registry)
192
187
  end
193
188
 
194
189
  ::MCP::Tool::Response.new([{
@@ -499,16 +494,7 @@ module PromptObjects
499
494
  }])
500
495
  end
501
496
 
502
- info = {
503
- name: po.name,
504
- description: po.description,
505
- state: po.state.to_s,
506
- prompt: po.body,
507
- capabilities: po.config["capabilities"] || [],
508
- config: po.config,
509
- session_id: po.instance_variable_get(:@session_id),
510
- history_length: po.history.length
511
- }
497
+ info = po.to_inspect_hash(registry: env.registry)
512
498
 
513
499
  ::MCP::Tool::Response.new([{
514
500
  type: "text",
@@ -42,6 +42,7 @@ module PromptObjects
42
42
  attr_accessor :on_po_registered # Callback for when a PO is registered
43
43
  attr_accessor :on_po_modified # Callback for when a PO is modified (capabilities changed, etc.)
44
44
  attr_accessor :on_delegation_event # Callback for PO-to-PO delegation start/complete
45
+ attr_accessor :on_env_data_changed # Callback for env data store/update/delete
45
46
 
46
47
  # Initialize from an environment path (with manifest) or objects directory.
47
48
  # @param env_path [String, nil] Path to environment directory (preferred)
@@ -223,6 +224,15 @@ module PromptObjects
223
224
  @on_delegation_event&.call(event_type, payload)
224
225
  end
225
226
 
227
+ # Notify that environment data has changed (stored, updated, or deleted).
228
+ # @param action [String] "store", "update", or "delete"
229
+ # @param root_thread_id [String] Root thread scope
230
+ # @param key [String] The data key that changed
231
+ # @param stored_by [String] PO name that made the change
232
+ def notify_env_data_changed(action:, root_thread_id:, key:, stored_by:)
233
+ @on_env_data_changed&.call(action: action, root_thread_id: root_thread_id, key: key, stored_by: stored_by)
234
+ end
235
+
226
236
  # Load a prompt object by name from the objects directory.
227
237
  # @param name [String] Name of the prompt object (without .md extension)
228
238
  # @return [PromptObject]
@@ -316,6 +326,11 @@ module PromptObjects
316
326
  @registry.register(Universal::ModifyPrimitive.new)
317
327
  @registry.register(Universal::RequestPrimitive.new)
318
328
  @registry.register(Universal::ModifyPrompt.new)
329
+ @registry.register(Universal::StoreEnvData.new)
330
+ @registry.register(Universal::GetEnvData.new)
331
+ @registry.register(Universal::ListEnvData.new)
332
+ @registry.register(Universal::UpdateEnvData.new)
333
+ @registry.register(Universal::DeleteEnvData.new)
319
334
  end
320
335
  end
321
336
 
@@ -37,7 +37,29 @@ module PromptObjects
37
37
  end
38
38
 
39
39
  raw_response = @client.chat(parameters: params)
40
+
41
+ # Check for error responses (Ollama and some providers return errors inline)
42
+ if raw_response.is_a?(Hash) && raw_response["error"]
43
+ error_msg = raw_response["error"]
44
+ error_msg = error_msg["message"] if error_msg.is_a?(Hash)
45
+ raise Error, "#{@provider_name} API error: #{error_msg}"
46
+ end
47
+
40
48
  parse_response(raw_response)
49
+ rescue Faraday::ClientError => e
50
+ # Extract error body from 4xx responses (ruby-openai wraps these)
51
+ body = e.response&.dig(:body) rescue nil
52
+ detail = if body.is_a?(String)
53
+ begin
54
+ parsed = JSON.parse(body)
55
+ parsed.dig("error", "message") || parsed["error"] || body
56
+ rescue JSON::ParserError
57
+ body
58
+ end
59
+ elsif body.is_a?(Hash)
60
+ body.dig("error", "message") || body["error"] || body.to_s
61
+ end
62
+ raise Error, "#{@provider_name} API error (#{e.response&.dig(:status)}): #{detail || e.message}"
41
63
  end
42
64
 
43
65
  private
@@ -30,37 +30,7 @@ module PromptObjects
30
30
  }])
31
31
  end
32
32
 
33
- # Categorize capabilities
34
- declared_caps = po.config["capabilities"] || []
35
- universal_caps = PromptObjects::UNIVERSAL_CAPABILITIES
36
-
37
- # Resolve which are POs vs primitives
38
- delegates = []
39
- primitives = []
40
-
41
- declared_caps.each do |cap_name|
42
- cap = env.registry.get(cap_name)
43
- if cap.is_a?(PromptObjects::PromptObject)
44
- delegates << cap_name
45
- elsif cap.is_a?(PromptObjects::Primitive)
46
- primitives << cap_name
47
- end
48
- end
49
-
50
- info = {
51
- name: po.name,
52
- description: po.description,
53
- state: po.state || :idle,
54
- config: po.config,
55
- capabilities: {
56
- universal: universal_caps,
57
- primitives: primitives,
58
- delegates: delegates,
59
- all_declared: declared_caps
60
- },
61
- prompt_body: po.body,
62
- history_length: po.history.length
63
- }
33
+ info = po.to_inspect_hash(registry: env.registry)
64
34
 
65
35
  ::MCP::Tool::Response.new([{
66
36
  type: "text",
@@ -17,12 +17,7 @@ module PromptObjects
17
17
  env = server_context[:env]
18
18
 
19
19
  pos = env.registry.prompt_objects.map do |po|
20
- {
21
- name: po.name,
22
- description: po.description,
23
- state: po.state || :idle,
24
- capabilities: po.config["capabilities"] || []
25
- }
20
+ po.to_summary_hash(registry: env.registry)
26
21
  end
27
22
 
28
23
  ::MCP::Tool::Response.new([{
@@ -232,8 +232,134 @@ module PromptObjects
232
232
  @session_id
233
233
  end
234
234
 
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]
244
+ def to_state_hash(registry: nil)
245
+ {
246
+ status: (@state || :idle).to_s,
247
+ description: description,
248
+ capabilities: resolve_capabilities(registry: registry),
249
+ 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
+ prompt: body,
253
+ config: config
254
+ }
255
+ end
256
+
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
+ def to_summary_hash(registry: nil)
262
+ {
263
+ name: name,
264
+ description: description,
265
+ status: (@state || :idle).to_s,
266
+ capabilities: resolve_capabilities(registry: registry),
267
+ session_count: list_sessions.size
268
+ }
269
+ end
270
+
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
+ def to_inspect_hash(registry: nil)
276
+ {
277
+ name: name,
278
+ description: description,
279
+ status: (@state || :idle).to_s,
280
+ capabilities: resolve_capabilities(registry: registry),
281
+ universal_capabilities: resolve_universal_capabilities(registry: registry),
282
+ prompt: body,
283
+ config: config,
284
+ session_id: session_id,
285
+ sessions: list_sessions.map { |s| self.class.serialize_session(s) },
286
+ history_length: history.length
287
+ }
288
+ end
289
+
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]
296
+ when :user
297
+ from = msg[:from] || msg[:from_po]
298
+ { role: "user", content: msg[:content], from: from }
299
+ 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 }
305
+ else
306
+ { id: tc[:id] || tc["id"], name: tc[:name] || tc["name"], arguments: tc[:arguments] || tc["arguments"] || {} }
307
+ end
308
+ end
309
+ end
310
+ hash
311
+ when :tool
312
+ results = msg[:results] || msg[:tool_results]
313
+ { role: "tool", results: results }
314
+ else
315
+ { role: msg[:role].to_s, content: msg[:content] }
316
+ end
317
+ end
318
+
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
+ def self.serialize_session(session)
323
+ {
324
+ id: session[:id],
325
+ name: session[:name],
326
+ message_count: session[:message_count] || 0,
327
+ updated_at: session[:updated_at]&.iso8601,
328
+ parent_session_id: session[:parent_session_id],
329
+ parent_po: session[:parent_po],
330
+ thread_type: session[:thread_type] || "root"
331
+ }
332
+ end
333
+
235
334
  private
236
335
 
336
+ # Build capability info objects for this PO's declared capabilities.
337
+ def resolve_capabilities(registry: nil)
338
+ 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 }
344
+ end
345
+ end
346
+
347
+ # Build capability info objects for universal capabilities.
348
+ def resolve_universal_capabilities(registry: nil)
349
+ return [] unless registry
350
+
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 }
354
+ end
355
+ end
356
+
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
+
237
363
  def normalize_message(message)
238
364
  case message
239
365
  when String
@@ -259,23 +385,118 @@ module PromptObjects
259
385
  end
260
386
 
261
387
  def build_system_prompt
262
- # Build context about this PO's identity
263
388
  declared_caps = @config["capabilities"] || []
264
- all_caps = declared_caps + UNIVERSAL_CAPABILITIES
265
389
 
266
390
  context_block = <<~CONTEXT
267
391
  ## System Context
268
392
 
269
- You are a prompt object named "#{name}".
393
+ You are a prompt object named "#{name}" running in a PromptObjects environment.
394
+
395
+ ### What is a Prompt Object?
396
+ You are an autonomous entity defined by a markdown file. You have an identity (your prompt),
397
+ capabilities (tools you can use), and you communicate by receiving messages and responding.
398
+ You exist alongside other prompt objects and primitive tools in a shared environment.
399
+
400
+ ### How you get called
401
+ You may receive messages from:
402
+ - **A human** interacting with you directly through the UI
403
+ - **Another prompt object** that has delegated a task to you as part of a larger workflow
404
+
405
+ When called by another PO, you'll see a delegation context block in the message with details
406
+ about who called you and why. You can also check shared environment data (via `list_env_data`)
407
+ for context that other POs in the same workflow have stored.
408
+
409
+ ### Your capabilities
270
410
  When using tools that target a PO (like add_capability), you can use "self" or "#{name}" to target yourself.
411
+ - Declared capabilities: #{declared_caps.empty? ? '(none)' : declared_caps.join(', ')}
412
+ - Universal capabilities (always available): #{UNIVERSAL_CAPABILITIES.join(', ')}
271
413
 
272
- Your declared capabilities: #{declared_caps.empty? ? '(none)' : declared_caps.join(', ')}
273
- Universal capabilities (always available): #{UNIVERSAL_CAPABILITIES.join(', ')}
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.
274
415
  CONTEXT
275
416
 
276
417
  "#{@body}\n\n#{context_block}"
277
418
  end
278
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
+
279
500
  def execute_tool_calls(tool_calls, context)
280
501
  # Track the caller for nested calls
281
502
  previous_capability = context.current_capability
@@ -284,6 +505,13 @@ module PromptObjects
284
505
  tool_calls.map do |tc|
285
506
  capability = @env.registry&.get(tc.name)
286
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
+
287
515
  if capability
288
516
  # Log the outgoing message
289
517
  @env.bus.publish(from: name, to: tc.name, message: tc.arguments)
@@ -328,6 +556,10 @@ module PromptObjects
328
556
  parent_message_id: get_last_message_id
329
557
  )
330
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
+
331
563
  # Notify delegation start so WebSocket clients see the target PO activate
332
564
  @env.notify_delegation(:started, {
333
565
  target: target_po.name,
@@ -339,10 +571,10 @@ module PromptObjects
339
571
  begin
340
572
  if delegation_thread
341
573
  # Execute in isolated thread
342
- target_po.receive_in_thread(tool_call.arguments, context: context, thread_id: delegation_thread)
574
+ target_po.receive_in_thread(enriched_args, context: context, thread_id: delegation_thread)
343
575
  else
344
576
  # Fallback: execute in target's current session (no session store)
345
- target_po.receive(tool_call.arguments, context: context)
577
+ target_po.receive(enriched_args, context: context)
346
578
  end
347
579
  ensure
348
580
  # Notify delegation complete — target PO is done