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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +33 -0
- data/CLAUDE.md +113 -44
- data/README.md +140 -14
- data/frontend/index.html +5 -1
- data/frontend/src/App.tsx +72 -79
- data/frontend/src/canvas/CanvasView.tsx +5 -5
- data/frontend/src/canvas/constants.ts +31 -31
- data/frontend/src/canvas/inspector/InspectorPanel.tsx +4 -4
- data/frontend/src/canvas/inspector/POInspector.tsx +35 -35
- data/frontend/src/canvas/inspector/ToolCallInspector.tsx +13 -13
- data/frontend/src/canvas/nodes/PONode.ts +2 -2
- data/frontend/src/components/ContextMenu.tsx +5 -4
- data/frontend/src/components/EnvDataPane.tsx +69 -0
- data/frontend/src/components/Inspector.tsx +263 -0
- data/frontend/src/components/MarkdownMessage.tsx +22 -20
- data/frontend/src/components/MethodList.tsx +90 -0
- data/frontend/src/components/ModelSelector.tsx +13 -14
- data/frontend/src/components/NotificationPanel.tsx +29 -33
- data/frontend/src/components/ObjectList.tsx +78 -0
- data/frontend/src/components/PaneSlot.tsx +30 -0
- data/frontend/src/components/SourcePane.tsx +202 -0
- data/frontend/src/components/SystemBar.tsx +74 -0
- data/frontend/src/components/Transcript.tsx +76 -0
- data/frontend/src/components/UsagePanel.tsx +27 -27
- data/frontend/src/components/Workspace.tsx +260 -0
- data/frontend/src/components/index.ts +10 -9
- data/frontend/src/hooks/useResize.ts +55 -0
- data/frontend/src/hooks/useWebSocket.ts +70 -0
- data/frontend/src/index.css +27 -10
- data/frontend/src/store/index.ts +36 -0
- data/frontend/src/types/index.ts +13 -0
- data/frontend/tailwind.config.js +28 -9
- data/lib/prompt_objects/capability.rb +23 -1
- data/lib/prompt_objects/connectors/mcp.rb +2 -16
- data/lib/prompt_objects/environment.rb +15 -0
- data/lib/prompt_objects/llm/openai_adapter.rb +22 -0
- data/lib/prompt_objects/mcp/tools/inspect_po.rb +1 -31
- data/lib/prompt_objects/mcp/tools/list_prompt_objects.rb +1 -6
- data/lib/prompt_objects/prompt_object.rb +239 -7
- data/lib/prompt_objects/server/api/routes.rb +16 -48
- data/lib/prompt_objects/server/app.rb +14 -0
- data/lib/prompt_objects/server/public/assets/{index-xvyeb-5Z.js → index-DEPawnfZ.js} +206 -206
- data/lib/prompt_objects/server/public/assets/index-oMrRce1m.css +1 -0
- data/lib/prompt_objects/server/public/index.html +7 -3
- data/lib/prompt_objects/server/websocket_handler.rb +41 -98
- data/lib/prompt_objects/server.rb +6 -62
- data/lib/prompt_objects/session/store.rb +176 -4
- data/lib/prompt_objects/universal/delete_env_data.rb +70 -0
- data/lib/prompt_objects/universal/get_env_data.rb +64 -0
- data/lib/prompt_objects/universal/list_env_data.rb +61 -0
- data/lib/prompt_objects/universal/store_env_data.rb +87 -0
- data/lib/prompt_objects/universal/update_env_data.rb +88 -0
- data/lib/prompt_objects.rb +6 -1
- data/prompt_objects.gemspec +1 -1
- data/templates/arc-agi-1/objects/observer.md +4 -0
- data/templates/arc-agi-1/objects/solver.md +10 -1
- data/templates/arc-agi-1/objects/verifier.md +4 -0
- data/templates/arc-agi-1/primitives/find_objects.rb +1 -1
- data/templates/arc-agi-1/primitives/grid_diff.rb +2 -2
- data/templates/arc-agi-1/primitives/grid_info.rb +1 -1
- data/templates/arc-agi-1/primitives/grid_transform.rb +1 -1
- data/templates/arc-agi-1/primitives/render_grid.rb +1 -0
- data/templates/arc-agi-1/primitives/test_solution.rb +3 -0
- data/tools/thread-explorer.html +27 -0
- metadata +18 -16
- data/Gemfile.lock +0 -233
- data/IMPLEMENTATION_PLAN.md +0 -1073
- data/design-doc-v2.md +0 -1232
- data/frontend/src/components/CapabilitiesPanel.tsx +0 -141
- data/frontend/src/components/ChatPanel.tsx +0 -296
- data/frontend/src/components/Dashboard.tsx +0 -83
- data/frontend/src/components/Header.tsx +0 -153
- data/frontend/src/components/MessageBus.tsx +0 -56
- data/frontend/src/components/POCard.tsx +0 -56
- data/frontend/src/components/PODetail.tsx +0 -124
- data/frontend/src/components/PromptPanel.tsx +0 -156
- data/frontend/src/components/SessionsPanel.tsx +0 -174
- data/frontend/src/components/ThreadsSidebar.tsx +0 -163
- data/lib/prompt_objects/server/public/assets/index-6y64NXFy.css +0 -1
data/frontend/src/store/index.ts
CHANGED
|
@@ -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] || [] : [])))
|
data/frontend/src/types/index.ts
CHANGED
|
@@ -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
|
|
data/frontend/tailwind.config.js
CHANGED
|
@@ -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: '#
|
|
13
|
-
surface: '#
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
577
|
+
target_po.receive(enriched_args, context: context)
|
|
346
578
|
end
|
|
347
579
|
ensure
|
|
348
580
|
# Notify delegation complete — target PO is done
|