little_ghost 0.2.0 → 0.3.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.
@@ -3,7 +3,7 @@
3
3
  require "securerandom"
4
4
 
5
5
  module LittleGhost
6
- # Observe one top-level agent or workflow execution from start to finish.
6
+ # Observe one top-level assembly execution from start to finish.
7
7
  # A run records its response, outcome, usage, error, and owned resources.
8
8
  #
9
9
  # run = CustomerSupportAgent.ask("Why is transfer 481 pending?")
@@ -12,11 +12,11 @@ module LittleGhost
12
12
  # run.outcome # => "completed"
13
13
  # run.response # => "Transfer 481 is waiting for the receiving bank."
14
14
  #
15
- # The class-level ask helper[rdoc-ref:LittleGhost::Agent.ask] or standalone
16
- # ask method[rdoc-ref:LittleGhost::Agent#ask] consumes the event stream and
15
+ # The class-level ask helper[rdoc-ref:LittleGhost::Assembly.ask] or standalone
16
+ # ask method[rdoc-ref:LittleGhost::Assembly#ask] consumes the event stream and
17
17
  # returns the Run. For a live interface, the class-level streaming
18
- # helper[rdoc-ref:LittleGhost::Agent.stream_ask] or standalone streaming
19
- # method[rdoc-ref:LittleGhost::Agent#stream_ask] yields StreamEvent objects
18
+ # helper[rdoc-ref:LittleGhost::Assembly.stream_ask] or standalone streaming
19
+ # method[rdoc-ref:LittleGhost::Assembly#stream_ask] yields StreamEvent objects
20
20
  # and returns the same run after enumeration. A run can execute only once.
21
21
  #
22
22
  # Completion, failure, deadline, and cancellation become the +completed+,
@@ -25,7 +25,7 @@ module LittleGhost
25
25
  # delivery, or instrumentation failures may still raise because the framework
26
26
  # cannot safely report a clean stop.
27
27
  #
28
- # The run opens its workspace, sandbox, session, and entrypoint, then closes
28
+ # The run opens its workspace, sandbox, session, and assembly entrypoint, then closes
29
29
  # registered resources in reverse order. +register+ extends that lifecycle for
30
30
  # application resources. Interruption is available only while an agent
31
31
  # entrypoint is active and unambiguous.
@@ -38,11 +38,17 @@ module LittleGhost
38
38
  :outcome, :response, :error, :session, :usage, :workspace, :sandbox
39
39
 
40
40
  # Creates a dormant run for +invocation+.
41
- def initialize(invocation:, agent_class:, runtime:, entrypoint_class: agent_class,
41
+ def initialize(invocation:, runtime:, agent_class: nil, assembly_class: nil, entrypoint_class: nil,
42
+ execution_class: nil,
42
43
  cancellation_token: Support::CancellationToken.new, workspace: nil, sandbox: nil)
44
+ entrypoint_class ||= assembly_class || agent_class
45
+ raise ArgumentError, "entrypoint_class is required" unless entrypoint_class
46
+ execution_class ||= assembly_class || entrypoint_class
47
+
43
48
  @runtime = runtime
44
49
  @agent_class = agent_class
45
50
  @entrypoint_class = entrypoint_class
51
+ @execution_class = execution_class
46
52
  @invocation = invocation
47
53
  @cancellation_token = cancellation_token
48
54
  @workspace = workspace
@@ -55,11 +61,15 @@ module LittleGhost
55
61
  @event_mutex = Mutex.new
56
62
  @subagent_instrumentation_mutex = Mutex.new
57
63
  @subagent_instrumentation = {}
64
+ @assembly_step_instrumentation_mutex = Mutex.new
65
+ @assembly_step_instrumentation = {}
58
66
  @exclusive_tools_mutex = Mutex.new
59
67
  @once_mutex = Mutex.new
60
68
  @once_keys = {}
61
69
  @interruption_mutex = Mutex.new
70
+ @interruption_condition = ConditionVariable.new
62
71
  @interruption_state = :not_started
72
+ @active_interruptions = 0
63
73
  @entrypoint = nil
64
74
  @usage = Usage.new
65
75
  end
@@ -110,6 +120,21 @@ module LittleGhost
110
120
  cancellation_token: Support::CancellationToken.new,
111
121
  deadline: nil
112
122
  )
123
+ interrupt_response_with do
124
+ [
125
+ message,
126
+ {
127
+ interruption_id:,
128
+ batch_key:,
129
+ metadata:,
130
+ cancellation_token:,
131
+ deadline:
132
+ }
133
+ ]
134
+ end
135
+ end
136
+
137
+ def interrupt_response_with # :nodoc:
113
138
  entrypoint = @interruption_mutex.synchronize do
114
139
  case @interruption_state
115
140
  when :not_started, :starting
@@ -118,20 +143,22 @@ module LittleGhost
118
143
  raise AgentInterruptError, "Run has already finished"
119
144
  end
120
145
 
146
+ @active_interruptions += 1
121
147
  @entrypoint
122
148
  end
123
- unless entrypoint.is_a?(Agent)
149
+ unless entrypoint.respond_to?(:interrupt_response)
124
150
  raise AgentInterruptError, "Run entrypoint does not support interruptions"
125
151
  end
126
152
 
127
- entrypoint.interrupt_response(
128
- message,
129
- interruption_id:,
130
- batch_key:,
131
- metadata:,
132
- cancellation_token:,
133
- deadline:
134
- )
153
+ message, options = yield
154
+ entrypoint.interrupt_response(message, **options)
155
+ ensure
156
+ if entrypoint
157
+ @interruption_mutex.synchronize do
158
+ @active_interruptions -= 1
159
+ @interruption_condition.broadcast
160
+ end
161
+ end
135
162
  end
136
163
 
137
164
  # Creates a RunContext with this run's cancellation token and deadline.
@@ -209,6 +236,10 @@ module LittleGhost
209
236
  outcome: cleanup_error ? :error : :cancelled,
210
237
  error_type: cleanup_error&.class&.name
211
238
  )
239
+ finish_remaining_assembly_step_instrumentation(
240
+ outcome: cleanup_error ? :error : :cancelled,
241
+ error_type: cleanup_error&.class&.name
242
+ )
212
243
  rescue => error
213
244
  errors << error
214
245
  end
@@ -229,7 +260,7 @@ module LittleGhost
229
260
  :run,
230
261
  parent: nil,
231
262
  **correlation_attributes,
232
- entrypoint_kind: workflow_run? ? :workflow : :agent,
263
+ entrypoint_kind:,
233
264
  workflow_name: workflow_run? ? entrypoint_name : nil,
234
265
  trace_context: invocation[:parent_trace_context],
235
266
  trace_links: invocation[:trace_links],
@@ -241,17 +272,13 @@ module LittleGhost
241
272
  workspace&.open(run: self)
242
273
  sandbox&.open(run: self)
243
274
  @session = runtime.open_session(self)
244
- agent = if entrypoint_class <= Agent
245
- runtime.build_agent(entrypoint_class, run: self)
246
- else
247
- entrypoint_class.new(run: self, runtime:)
248
- end
275
+ agent = runtime.build_assembly(@execution_class, run: self)
249
276
  @interruption_mutex.synchronize do
250
277
  @entrypoint = agent
251
278
  end
252
279
  register(agent)
253
280
  invoke = lambda do
254
- history = session ? session.history(fallback: invocation.history) : invocation.history
281
+ history = session ? runtime.session_history(self, session, fallback: invocation.history) : invocation.history
255
282
  context = session ? session.state.merge(invocation.context) : invocation.context.dup
256
283
  options = {
257
284
  history:,
@@ -333,9 +360,10 @@ module LittleGhost
333
360
  ]
334
361
  ensure
335
362
  @interruption_mutex.synchronize do
363
+ @interruption_state = :terminal
364
+ @interruption_condition.wait(@interruption_mutex) while @active_interruptions.positive?
336
365
  @last_entrypoint = @entrypoint
337
366
  @entrypoint = nil
338
- @interruption_state = :terminal
339
367
  end
340
368
  resource_cleanup_error = nil
341
369
  begin
@@ -402,25 +430,39 @@ module LittleGhost
402
430
  end
403
431
 
404
432
  def correlation_attributes
405
- {
433
+ attributes = {
406
434
  operation_id:,
407
435
  run_id: invocation.run_id,
408
436
  invocation_id: invocation.invocation_id,
409
437
  session_id: invocation.session_id,
410
- service_name: runtime.service_name,
411
- agent_id: workflow_run? ? nil : entrypoint_name,
412
- workflow_name: workflow_run? ? entrypoint_name : nil
413
- }.compact
438
+ service_name: runtime.service_name
439
+ }
440
+ case entrypoint_kind
441
+ when :agent
442
+ attributes[:agent_id] = entrypoint_name
443
+ when :workflow
444
+ attributes[:workflow_name] = entrypoint_name
445
+ else
446
+ attributes[:assembly_id] = entrypoint_name
447
+ attributes[:assembly_kind] = entrypoint_kind
448
+ end
449
+ attributes
414
450
  end
415
451
 
416
- def workflow_run? = entrypoint_class <= Workflow
452
+ def workflow_run? = defined?(Workflow) && entrypoint_class <= Workflow
453
+
454
+ def entrypoint_kind
455
+ return entrypoint_class.assembly_kind if entrypoint_class.respond_to?(:assembly_kind)
456
+
457
+ workflow_run? ? :workflow : :agent
458
+ end
417
459
 
418
460
  def entrypoint_name
419
461
  return entrypoint_class.name.to_s if workflow_run?
420
462
 
421
- return @entrypoint.entrypoint_name if @entrypoint.is_a?(Agent)
463
+ return @entrypoint.entrypoint_name if @entrypoint&.respond_to?(:entrypoint_name)
422
464
 
423
- entrypoint_class.agent_id
465
+ entrypoint_class.respond_to?(:assembly_id) ? entrypoint_class.assembly_id : entrypoint_class.name.to_s
424
466
  end
425
467
 
426
468
  def instrument_subagent(data)
@@ -485,7 +527,53 @@ module LittleGhost
485
527
  error_class ||= error if error.is_a?(String) && error.match?(/\A[A-Z]\w*(?:::[A-Z]\w*)*\z/)
486
528
  attributes = data.slice(:attempt, :delay, :error_code, :http_status, :partial_text)
487
529
  instrument(:model_retry, attributes.merge(error_class:).compact)
530
+ when :assembly_step_start
531
+ start_assembly_step_instrumentation(data)
532
+ when :assembly_step_stop
533
+ finish_assembly_step_instrumentation(data.fetch(:step_id), data.merge(outcome: :completed))
534
+ when :assembly_step_error
535
+ return unless data[:terminal]
536
+
537
+ finish_assembly_step_instrumentation(data.fetch(:step_id), data.merge(outcome: :error))
538
+ when :assembly_step_retry, :assembly_fork, :assembly_join, :assembly_transition, :assembly_handoff_loop
539
+ instrument(type, data.except(:usage))
540
+ end
541
+ end
542
+
543
+ def start_assembly_step_instrumentation(attributes)
544
+ step_id = attributes.fetch(:step_id)
545
+ handle = Instrumentation.start(
546
+ :assembly_step,
547
+ parent: attributes[:parent_operation_id] || operation_id,
548
+ operation_id: step_id,
549
+ detached: true,
550
+ assembly_id: attributes[:assembly_id],
551
+ assembly_kind: attributes[:assembly_kind],
552
+ participant: attributes[:participant],
553
+ branch_id: attributes[:branch_id]
554
+ )
555
+ @assembly_step_instrumentation_mutex.synchronize { @assembly_step_instrumentation[step_id] = handle }
556
+ end
557
+
558
+ def finish_assembly_step_instrumentation(step_id, attributes)
559
+ handle = @assembly_step_instrumentation_mutex.synchronize do
560
+ @assembly_step_instrumentation.delete(step_id)
561
+ end
562
+ return unless handle
563
+
564
+ usage = attributes[:usage]
565
+ handle.finish(
566
+ outcome: attributes[:outcome],
567
+ error_type: attributes[:error_type],
568
+ **(usage ? usage_attributes(usage) : {})
569
+ )
570
+ end
571
+
572
+ def finish_remaining_assembly_step_instrumentation(outcome:, error_type: nil)
573
+ handles = @assembly_step_instrumentation_mutex.synchronize do
574
+ @assembly_step_instrumentation.values.tap { @assembly_step_instrumentation.clear }
488
575
  end
576
+ handles.reverse_each { |handle| handle.finish(outcome:, error_type:) }
489
577
  end
490
578
 
491
579
  def start_subagent_instrumentation(attributes)
@@ -4,16 +4,16 @@ module LittleGhost
4
4
  # Associates a validated structured value with its declared schema name.
5
5
  StructuredResult = Data.define(:schema_name, :value) # :nodoc:
6
6
 
7
- # RunResult gives callers one final view of an agent or workflow invocation.
8
- # It includes the response, usage, updated conversation, state, and any
9
- # validated structured value.
7
+ # RunResult gives callers one final view of an assembly invocation.
8
+ # It includes the response, usage, updated conversation, state, coordination
9
+ # steps, and any validated structured value.
10
10
  #
11
11
  # Use #output when the caller should accept either structured or textual
12
12
  # agents. It returns the validated structured value when present and #text
13
13
  # otherwise.
14
- RunResult = Data.define(:message, :stop_reason, :usage, :messages, :state, :structured_result) do # :nodoc:
15
- def initialize(message:, stop_reason:, usage:, messages:, state:, structured_result: nil)
16
- super
14
+ RunResult = Data.define(:message, :stop_reason, :usage, :messages, :state, :structured_result, :steps) do # :nodoc:
15
+ def initialize(message:, stop_reason:, usage:, messages:, state:, structured_result: nil, steps: [])
16
+ super(message:, stop_reason:, usage:, messages:, state:, structured_result:, steps: Array(steps).freeze)
17
17
  end
18
18
 
19
19
  # Reads the final message text, or an empty string when no message exists.
@@ -30,6 +30,9 @@ module LittleGhost
30
30
  def output
31
31
  structured? ? structured_result.value : text
32
32
  end
33
+
34
+ # Returns immutable coordination queries for this result.
35
+ def trajectory = Assembly::Trajectory.new(steps)
33
36
  end
34
37
 
35
38
  # Associates a validated structured value with its declared schema name.
@@ -56,9 +59,9 @@ module LittleGhost
56
59
  # The locally validated application value.
57
60
  end
58
61
 
59
- # RunResult gives callers one final view of an agent or workflow invocation.
60
- # It includes the response, usage, updated conversation, state, and any
61
- # validated structured value.
62
+ # RunResult gives callers one final view of an Assembly invocation.
63
+ # It includes the response, usage, updated conversation, state, coordination
64
+ # steps, and any validated structured value.
62
65
  #
63
66
  # Use #output when the caller should accept either structured or textual
64
67
  # agents. It returns the validated structured value when present and #text
@@ -68,9 +71,9 @@ module LittleGhost
68
71
  # :singleton-method: new
69
72
  # :call-seq:
70
73
  # new(message:, stop_reason:, usage:, messages:, state:,
71
- # structured_result: nil) -> RunResult
74
+ # structured_result: nil, steps: []) -> RunResult
72
75
  #
73
- # Creates the terminal value for one agent or workflow invocation.
76
+ # Creates the terminal value for one Assembly invocation.
74
77
 
75
78
  ##
76
79
  # :attr_reader: message
@@ -96,6 +99,10 @@ module LittleGhost
96
99
  # :attr_reader: structured_result
97
100
  # The validated StructuredResult, or +nil+ for a textual result.
98
101
 
102
+ ##
103
+ # :attr_reader: steps
104
+ # Immutable Assembly::Step records for composite invocations.
105
+
99
106
  ##
100
107
  # :method: text
101
108
  # Reads the final message text, or an empty string when no message exists.
@@ -107,5 +114,9 @@ module LittleGhost
107
114
  ##
108
115
  # :method: output
109
116
  # Uses the structured value when present and otherwise #text.
117
+
118
+ ##
119
+ # :method: trajectory
120
+ # Returns an Assembly::Trajectory for querying immutable coordination steps.
110
121
  end
111
122
  end
@@ -2,8 +2,8 @@
2
2
 
3
3
  module LittleGhost
4
4
  class Runtime
5
- # Hooks let applications prepare runs, transform interruptions, and map
6
- # errors to caller-safe messages.
5
+ # Hooks let applications prepare runs, select session history, transform
6
+ # interruptions, and map errors to caller-safe messages.
7
7
  #
8
8
  # Hooks are instantiated once per Runtime in configuration order. Override
9
9
  # only the methods needed and return the supplied value when leaving it
@@ -23,6 +23,11 @@ module LittleGhost
23
23
  # Transforms an interruption payload before it reaches the agent.
24
24
  def prepare_interruption(_run, payload) = payload
25
25
 
26
+ # Returns the history to use for this run, or nil to defer to later hooks
27
+ # and the session default. +stored+ is empty when the session is new;
28
+ # +fallback+ contains messages supplied by the invocation.
29
+ def session_history(_run, stored:, fallback:) = nil
30
+
26
31
  # Returns a caller-safe error message, or nil to defer to later hooks and
27
32
  # the runtime default. Avoid exposing secrets or internal exception text.
28
33
  def error_message(_error, _run) = nil
@@ -4,7 +4,7 @@ require "json"
4
4
  require_relative "configuration"
5
5
 
6
6
  module LittleGhost
7
- # Prepare the shared services that agents and workflows use across many runs.
7
+ # Prepare the shared services that assemblies use across many runs.
8
8
  # A runtime owns model resolution, loading, persistence, lookup paths, hooks,
9
9
  # and resource factories for one Ruby setup.
10
10
  #
@@ -91,8 +91,8 @@ module LittleGhost
91
91
  @prompt_paths = build_lookup_paths(:prompt_paths)
92
92
  @skill_paths = build_lookup_paths(:skill_paths)
93
93
 
94
- @startup_phase = "agent_builder"
95
- @agent_builder = AgentBuilder.new(
94
+ @startup_phase = "agent_factory"
95
+ @agent_factory = AgentFactory.new(
96
96
  runtime: self,
97
97
  prompt_paths: @prompt_paths,
98
98
  resolve_agent: method(:resolve_agent_class)
@@ -133,11 +133,19 @@ module LittleGhost
133
133
  # sandbox resources to it.
134
134
  def build_run(
135
135
  payload,
136
- agent_class:,
137
- entrypoint_class: agent_class,
136
+ agent_class: nil,
137
+ assembly_class: nil,
138
+ entrypoint_class: nil,
139
+ execution_class: nil,
140
+ cancellation_token: Support::CancellationToken.new,
138
141
  workspace: nil,
139
142
  sandbox: nil
140
143
  )
144
+ entrypoint_class ||= assembly_class || agent_class
145
+ raise ArgumentError, "entrypoint_class is required" unless entrypoint_class
146
+
147
+ execution_class ||= assembly_class || entrypoint_class
148
+ agent_class ||= entrypoint_class if entrypoint_class <= Agent
141
149
  owned_resources = []
142
150
  workspace ||= build_workspace.tap { |resource| owned_resources << resource }
143
151
  sandbox ||= build_sandbox(workspace:).tap { |resource| owned_resources << resource }
@@ -146,6 +154,8 @@ module LittleGhost
146
154
  runtime: self,
147
155
  agent_class:,
148
156
  entrypoint_class:,
157
+ execution_class:,
158
+ cancellation_token:,
149
159
  workspace:,
150
160
  sandbox:
151
161
  )
@@ -183,7 +193,41 @@ module LittleGhost
183
193
  tools: [],
184
194
  agent_path: Subagents::AgentPath::ROOT
185
195
  )
186
- @agent_builder.build(agent_class_or_name, run:, model:, tools:, agent_path:)
196
+ agent_class_or_name = agent_class_or_name.definition if agent_class_or_name.is_a?(AgentBuilder)
197
+ if agent_class_or_name.is_a?(AssemblyDefinition)
198
+ unless agent_class_or_name.kind == :agent
199
+ raise ConfigurationError, "agent definition must have kind :agent"
200
+ end
201
+ agent_class_or_name = agent_class_or_name.implementation
202
+ end
203
+ @agent_factory.build(agent_class_or_name, run:, model:, tools:, agent_path:)
204
+ end
205
+
206
+ # Builds an Agent through AgentFactory or constructs another Assembly for +run+.
207
+ def build_assembly(assembly_class_or_name, run:, **options) # :nodoc:
208
+ if assembly_class_or_name.is_a?(AssemblyBuilder)
209
+ assembly_class_or_name = assembly_class_or_name.definition
210
+ end
211
+ if assembly_class_or_name.is_a?(Class) && assembly_class_or_name <= Assembly
212
+ assembly_class_or_name = assembly_class_or_name.definition
213
+ end
214
+ if assembly_class_or_name.is_a?(AssemblyDefinition)
215
+ return build_agent(assembly_class_or_name, run:, **options) if assembly_class_or_name.kind == :agent
216
+ raise ArgumentError, "composite assembly definitions do not accept agent build options" unless options.empty?
217
+
218
+ return assembly_class_or_name.implementation.new(run:, runtime: self)
219
+ end
220
+ if !assembly_class_or_name.is_a?(Class) || assembly_class_or_name <= Agent
221
+ return build_agent(assembly_class_or_name, run:, **options)
222
+ end
223
+ unless assembly_class_or_name <= Assembly
224
+ raise ConfigurationError, "assembly must inherit from LittleGhost::Assembly"
225
+ end
226
+ unless options.empty?
227
+ raise ArgumentError, "composite assemblies do not accept agent build options"
228
+ end
229
+
230
+ assembly_class_or_name.new(run:, runtime: self)
187
231
  end
188
232
 
189
233
  # The low-cardinality service name attached to runtime telemetry.
@@ -205,6 +249,16 @@ module LittleGhost
205
249
  )
206
250
  end
207
251
 
252
+ def session_history(run, session, fallback:) # :nodoc:
253
+ stored = session.history
254
+ runtime_hooks.each do |hook|
255
+ history = hook.session_history(run, stored:, fallback:)
256
+ return normalize_history(history) unless history.nil?
257
+ end
258
+
259
+ session.history(fallback:)
260
+ end
261
+
208
262
  def prepare_run(run) # :nodoc:
209
263
  runtime_hooks.each { |hook| hook.prepare_run(run) }
210
264
  run
@@ -282,6 +336,10 @@ module LittleGhost
282
336
  Array(hook_classes).map(&:new)
283
337
  end
284
338
 
339
+ def normalize_history(history)
340
+ Array(history).map { |message| Message.coerce(message) }.freeze
341
+ end
342
+
285
343
  def build_session_store(definition)
286
344
  return SessionStores::Memory.new unless definition
287
345
 
@@ -20,7 +20,7 @@ module LittleGhost
20
20
  # result = @container.run(
21
21
  # command, timeout:, cancellation: context&.cancellation_token
22
22
  # )
23
- # Execution.new(**result)
23
+ # LittleGhost::Sandbox::Execution.new(**result)
24
24
  # end
25
25
  # end
26
26
  #
@@ -31,6 +31,8 @@ module LittleGhost
31
31
  items.each_index { |index| queue << index }
32
32
  results = Array.new(items.length)
33
33
  errors = Array.new(items.length)
34
+ error_mutex = Mutex.new
35
+ first_worker_error = nil
34
36
  worker_count = [@max_concurrency, items.length].min
35
37
  execution_state = ExecutionState.capture
36
38
 
@@ -49,6 +51,8 @@ module LittleGhost
49
51
  results[index] = block.call(items[index])
50
52
  rescue => error
51
53
  errors[index] = error
54
+ error_mutex.synchronize { first_worker_error ||= error }
55
+ cancellation_token.cancel
52
56
  ensure
53
57
  completions << index
54
58
  end
@@ -56,16 +60,24 @@ module LittleGhost
56
60
  end
57
61
  end
58
62
  end
63
+ callback_error = nil
59
64
  begin
60
65
  items.length.times do
61
66
  index = completions.pop
62
- on_result.call(index, results[index]) if on_result && !errors[index]
67
+ begin
68
+ on_result.call(index, results[index]) if on_result && !errors[index]
69
+ rescue => error
70
+ callback_error = error
71
+ cancellation_token.cancel
72
+ break
73
+ end
63
74
  end
64
75
  ensure
65
76
  workers.each(&:join)
66
77
  end
67
78
 
68
- first_error = errors.compact.find { |error| error.is_a?(CleanupError) } || errors.compact.first
79
+ raise callback_error if callback_error
80
+ first_error = errors.compact.find { |error| error.is_a?(CleanupError) } || first_worker_error
69
81
  raise first_error if first_error
70
82
 
71
83
  results
@@ -5,7 +5,7 @@ require "monitor"
5
5
 
6
6
  module LittleGhost
7
7
  module Support
8
- # Loader finds agents and tools from a conventional application layout. Ruby
8
+ # Loader finds agents, assemblies, and tools from a conventional application layout. Ruby
9
9
  # files map to constants by path, so applications can add extension classes
10
10
  # without maintaining a manual require list.
11
11
  #
@@ -16,7 +16,7 @@ module LittleGhost
16
16
  # checked for symbolic-link escapes. Application load roots are trusted code,
17
17
  # not a sandbox for untrusted files.
18
18
  class Loader
19
- DEFAULT_DIRECTORIES = %w[app/agents app/tools].freeze # :nodoc:
19
+ DEFAULT_DIRECTORIES = %w[app/agents app/assemblies app/tools].freeze # :nodoc:
20
20
  PROCESS_LOCK = Monitor.new # :nodoc:
21
21
 
22
22
  # Application root and configured relative load directories.
@@ -9,17 +9,29 @@ module LittleGhost
9
9
 
10
10
  # Recursively duplicates hashes, arrays, and strings while preserving other
11
11
  # values. Cyclic containers are not supported.
12
- def deep_dup(value)
12
+ def deep_dup(value, ancestors = {})
13
+ if value.is_a?(Hash) || value.is_a?(Array) || value.is_a?(Data)
14
+ identity = value.object_id
15
+ raise ArgumentError, "cyclic values cannot be duplicated" if ancestors.key?(identity)
16
+
17
+ ancestors[identity] = true
18
+ end
13
19
  case value
14
20
  when Hash
15
- value.each_with_object({}) { |(key, child), copy| copy[deep_dup(key)] = deep_dup(child) }
21
+ value.each_with_object({}) do |(key, child), copy|
22
+ copy[deep_dup(key, ancestors)] = deep_dup(child, ancestors)
23
+ end
16
24
  when Array
17
- value.map { |child| deep_dup(child) }
25
+ value.map { |child| deep_dup(child, ancestors) }
18
26
  when String
19
27
  value.dup
28
+ when Data
29
+ value.class.new(**value.to_h.transform_values { |child| deep_dup(child, ancestors) })
20
30
  else
21
31
  value
22
32
  end
33
+ ensure
34
+ ancestors.delete(identity) if identity
23
35
  end
24
36
  end
25
37
  end