little_ghost 0.2.1 → 0.4.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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +72 -74
  3. data/docs/guides/assemblies.md +286 -0
  4. data/docs/guides/core_concepts.md +159 -135
  5. data/docs/guides/getting_started.md +114 -83
  6. data/docs/guides/production.md +187 -0
  7. data/docs/guides/prompt_views.md +132 -0
  8. data/lib/little_ghost/ag_ui/adapter.rb +3 -3
  9. data/lib/little_ghost/agent/delegation.rb +35 -8
  10. data/lib/little_ghost/agent/tool_loop.rb +2 -1
  11. data/lib/little_ghost/agent.rb +280 -326
  12. data/lib/little_ghost/agent_builder.rb +20 -4
  13. data/lib/little_ghost/agent_factory.rb +3 -0
  14. data/lib/little_ghost/{agent_interruptions.rb → agent_interjections.rb} +12 -12
  15. data/lib/little_ghost/assembly.rb +345 -0
  16. data/lib/little_ghost/assembly_builder.rb +497 -0
  17. data/lib/little_ghost/assembly_execution.rb +535 -0
  18. data/lib/little_ghost/configuration.rb +263 -39
  19. data/lib/little_ghost/content.rb +5 -5
  20. data/lib/little_ghost/data_map.rb +209 -0
  21. data/lib/little_ghost/errors.rb +10 -2
  22. data/lib/little_ghost/execution.rb +206 -0
  23. data/lib/little_ghost/graph.rb +930 -0
  24. data/lib/little_ghost/message.rb +4 -4
  25. data/lib/little_ghost/model_resolver.rb +2 -2
  26. data/lib/little_ghost/prompt_resolver.rb +2 -0
  27. data/lib/little_ghost/run.rb +190 -64
  28. data/lib/little_ghost/run_context.rb +33 -20
  29. data/lib/little_ghost/run_result.rb +22 -11
  30. data/lib/little_ghost/runtime/hook.rb +9 -4
  31. data/lib/little_ghost/runtime.rb +134 -36
  32. data/lib/little_ghost/sandbox.rb +1 -1
  33. data/lib/little_ghost/session.rb +12 -23
  34. data/lib/little_ghost/session_store.rb +9 -5
  35. data/lib/little_ghost/session_stores/agent_core_memory.rb +64 -56
  36. data/lib/little_ghost/session_stores/filesystem.rb +261 -0
  37. data/lib/little_ghost/session_stores/memory.rb +7 -0
  38. data/lib/little_ghost/subagents/manager.rb +42 -42
  39. data/lib/little_ghost/support/executor.rb +14 -2
  40. data/lib/little_ghost/support/loader.rb +2 -2
  41. data/lib/little_ghost/support.rb +15 -3
  42. data/lib/little_ghost/swarm.rb +439 -0
  43. data/lib/little_ghost/tool.rb +88 -20
  44. data/lib/little_ghost/tools/write_todos.rb +6 -1
  45. data/lib/little_ghost/tracing/open_telemetry.rb +14 -3
  46. data/lib/little_ghost/unrestricted_sandbox.rb +1 -1
  47. data/lib/little_ghost/version.rb +1 -1
  48. data/lib/little_ghost/workflow.rb +224 -90
  49. data/lib/little_ghost.rb +36 -25
  50. metadata +17 -5
@@ -0,0 +1,261 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+ require "securerandom"
7
+ require_relative "../data_map"
8
+ require_relative "../session_store"
9
+
10
+ module LittleGhost
11
+ module SessionStores
12
+ # Filesystem preserves LittleGhost sessions across process restarts in an
13
+ # application-controlled directory. Use it for durable local development,
14
+ # a single-host service, or processes that share a suitable filesystem.
15
+ #
16
+ # store = LittleGhost::SessionStores::Filesystem.new(
17
+ # root: "/var/lib/customer_support/sessions"
18
+ # )
19
+ #
20
+ # Configure the resulting store through Configuration#session_store, or
21
+ # pass it directly when opening a Session. Calls for the same session wait
22
+ # for one writer, including when separate Ruby processes share the root.
23
+ #
24
+ # **Warning:** The root contains readable session data and is not encrypted.
25
+ # Its complete path must be application-controlled: anyone able to read it
26
+ # can read session data, and anyone able to replace it can alter sessions.
27
+ #
28
+ # Session data is stored as ordinary JSON with canonical String keys. A
29
+ # value outside that boundary raises ProtocolError without replacing the
30
+ # previous snapshot. Shared roots require filesystem support for file
31
+ # locking and atomic rename.
32
+ class Filesystem < SessionStore
33
+ FORMAT_VERSION = 1 # :nodoc:
34
+
35
+ # Creates a store rooted at +root+ and creates the directory when needed.
36
+ #
37
+ # +root+ must be a private, non-symlinked directory. The application owns
38
+ # the complete path and must not let an untrusted request choose it.
39
+ # Raises ArgumentError when the root does not meet those requirements.
40
+ def initialize(root:)
41
+ super()
42
+ @root = File.expand_path(String(root))
43
+ raise ArgumentError, "root must not be empty" if root.to_s.empty?
44
+
45
+ FileUtils.mkdir_p(@root, mode: 0o700)
46
+ validate_root!
47
+ end
48
+
49
+ # Returns the stored snapshot for +id+, or +nil+ before the first write.
50
+ #
51
+ # +actor_id+ must match the actor that created an existing session.
52
+ # Raises Error for an actor mismatch and ProtocolError for an invalid or
53
+ # unsafe persisted snapshot.
54
+ def load(id, actor_id: nil)
55
+ with_lock(id) do
56
+ snapshot = read_snapshot(id)
57
+ validate_actor!(snapshot, actor_id) if snapshot
58
+ snapshot
59
+ end
60
+ end
61
+
62
+ # Atomically appends sanitized +messages+ and returns the updated snapshot.
63
+ #
64
+ # +expected_count+ must match the stored history length. +state+ and
65
+ # +metadata+ must meet this store's JSON boundary. Raises ProtocolError
66
+ # when another writer changed the session or the snapshot cannot be read
67
+ # or written. Raises Error when +actor_id+ does not match the session.
68
+ def append(id, messages:, state:, metadata:, expected_count:, actor_id: nil)
69
+ messages = persistable_messages(messages)
70
+ state = canonical_map(state)
71
+ metadata = canonical_map(metadata)
72
+ with_lock(id) do
73
+ current = read_snapshot(id)
74
+ validate_actor!(current, actor_id) if current
75
+ current ||= empty_snapshot(actor_id)
76
+ unless current.fetch(:messages).length == expected_count
77
+ raise ProtocolError, "Session changed while it was being updated"
78
+ end
79
+
80
+ snapshot = {
81
+ actor_digest: current.fetch(:actor_digest),
82
+ messages: [*current.fetch(:messages), *messages].freeze,
83
+ state:,
84
+ metadata:
85
+ }.freeze
86
+ write_snapshot(id, snapshot)
87
+ public_snapshot(snapshot)
88
+ end
89
+ end
90
+
91
+ # Atomically replaces the complete persisted snapshot and returns it.
92
+ #
93
+ # +state+, +metadata+, and +actor_id+ follow the same requirements as
94
+ # #append.
95
+ def replace(id, messages:, state:, metadata:, actor_id: nil)
96
+ messages = persistable_messages(messages)
97
+ state = canonical_map(state)
98
+ metadata = canonical_map(metadata)
99
+ with_lock(id) do
100
+ current = read_snapshot(id)
101
+ validate_actor!(current, actor_id) if current
102
+ snapshot = {
103
+ actor_digest: current ? current.fetch(:actor_digest) : actor_digest(actor_id),
104
+ messages: messages.freeze,
105
+ state:,
106
+ metadata:
107
+ }.freeze
108
+ write_snapshot(id, snapshot)
109
+ public_snapshot(snapshot)
110
+ end
111
+ end
112
+
113
+ private
114
+
115
+ def empty_snapshot(actor_id)
116
+ {
117
+ actor_digest: actor_digest(actor_id),
118
+ messages: [].freeze,
119
+ state: {}.freeze,
120
+ metadata: {}.freeze
121
+ }.freeze
122
+ end
123
+
124
+ def public_snapshot(snapshot)
125
+ snapshot.slice(:messages, :state, :metadata)
126
+ end
127
+
128
+ def validate_actor!(snapshot, actor_id)
129
+ return if snapshot.fetch(:actor_digest) == actor_digest(actor_id)
130
+
131
+ raise Error, "Session actor does not match"
132
+ end
133
+
134
+ def with_lock(id)
135
+ path = lock_path(id)
136
+ validate_entry!(path) if File.exist?(path) || File.symlink?(path)
137
+ File.open(path, File::RDWR | File::CREAT, 0o600) do |lock|
138
+ lock.flock(File::LOCK_EX)
139
+ yield
140
+ ensure
141
+ lock.flock(File::LOCK_UN)
142
+ end
143
+ end
144
+
145
+ def read_snapshot(id)
146
+ path = snapshot_path(id)
147
+ return unless File.exist?(path) || File.symlink?(path)
148
+
149
+ validate_entry!(path)
150
+
151
+ document = JSON.parse(File.binread(path))
152
+ validate_document!(document)
153
+ snapshot = document.fetch("snapshot")
154
+ validate_snapshot!(snapshot)
155
+ {
156
+ actor_digest: document.fetch("actor_digest"),
157
+ messages: Array(snapshot.fetch("messages")).map { |message| Message.coerce(message) }.freeze,
158
+ state: snapshot.fetch("state"),
159
+ metadata: snapshot.fetch("metadata")
160
+ }.freeze
161
+ rescue JSON::ParserError, KeyError, TypeError, ArgumentError, NoMethodError => error
162
+ raise ProtocolError, "Filesystem session snapshot is invalid: #{error.class}"
163
+ end
164
+
165
+ def write_snapshot(id, snapshot)
166
+ serialized_snapshot = public_snapshot(snapshot).merge(
167
+ messages: snapshot.fetch(:messages).map(&:to_h)
168
+ )
169
+ document = {
170
+ "version" => FORMAT_VERSION,
171
+ "actor_digest" => snapshot.fetch(:actor_digest),
172
+ "snapshot" => serialized_snapshot
173
+ }
174
+ document["digest"] = Digest::SHA256.hexdigest(JSON.generate(document))
175
+ write_atomically(snapshot_path(id), JSON.generate(document))
176
+ rescue JSON::GeneratorError, TypeError => error
177
+ raise ProtocolError, "Filesystem session snapshot could not be serialized: #{error.class}"
178
+ end
179
+
180
+ def validate_document!(document)
181
+ raise ArgumentError unless document.is_a?(Hash)
182
+ raise ArgumentError unless document.fetch("version") == FORMAT_VERSION
183
+ raise ArgumentError unless document.fetch("actor_digest").match?(/\A[0-9a-f]{64}\z/)
184
+ digest = document.fetch("digest")
185
+ raise ArgumentError unless digest.is_a?(String) && digest.match?(/\A[0-9a-f]{64}\z/)
186
+
187
+ expected = document.except("digest")
188
+ raise ArgumentError unless Digest::SHA256.hexdigest(JSON.generate(expected)) == digest
189
+ end
190
+
191
+ def validate_snapshot!(snapshot)
192
+ raise ArgumentError unless snapshot.is_a?(Hash)
193
+ raise ArgumentError unless snapshot.fetch("messages").is_a?(Array)
194
+ DataMap.new(snapshot.fetch("state"))
195
+ DataMap.new(snapshot.fetch("metadata"))
196
+ end
197
+
198
+ def canonical_map(value)
199
+ DataMap.new(value).to_h
200
+ rescue ArgumentError => error
201
+ raise ProtocolError, "Filesystem session data is invalid: #{error.message}"
202
+ end
203
+
204
+ def write_atomically(path, content)
205
+ validate_entry!(path) if File.exist?(path) || File.symlink?(path)
206
+ temporary = "#{path}.#{Process.pid}.#{SecureRandom.hex(8)}.tmp"
207
+ File.open(temporary, "wb", 0o600) do |file|
208
+ file.write(content)
209
+ file.flush
210
+ file.fsync
211
+ end
212
+ File.rename(temporary, path)
213
+ sync_root
214
+ ensure
215
+ File.delete(temporary) if temporary && File.exist?(temporary)
216
+ end
217
+
218
+ def sync_root
219
+ File.open(@root, File::RDONLY) { |directory| directory.fsync }
220
+ rescue SystemCallError
221
+ nil
222
+ end
223
+
224
+ def validate_root!
225
+ stat = File.lstat(@root)
226
+ unless stat.directory? && !stat.symlink? && private_mode?(stat)
227
+ raise ArgumentError, "root must be a private, non-symlinked directory"
228
+ end
229
+ end
230
+
231
+ def validate_entry!(path)
232
+ stat = File.lstat(path)
233
+ unless stat.file? && !stat.symlink? && private_mode?(stat)
234
+ raise ProtocolError, "Filesystem session entry is unsafe"
235
+ end
236
+ rescue Errno::ENOENT
237
+ nil
238
+ end
239
+
240
+ def private_mode?(stat)
241
+ (stat.mode & 0o077).zero?
242
+ end
243
+
244
+ def snapshot_path(id)
245
+ File.join(@root, "#{session_digest(id)}.json")
246
+ end
247
+
248
+ def lock_path(id)
249
+ File.join(@root, "#{session_digest(id)}.lock")
250
+ end
251
+
252
+ def session_digest(id)
253
+ Digest::SHA256.hexdigest("little_ghost/filesystem_session/v1\0#{String(id)}")
254
+ end
255
+
256
+ def actor_digest(actor_id)
257
+ Digest::SHA256.hexdigest(JSON.generate([actor_id.nil?, actor_id&.to_s]))
258
+ end
259
+ end
260
+ end
261
+ end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../data_map"
4
+ require_relative "../session_store"
5
+
3
6
  module LittleGhost
4
7
  # Ready-to-use persistence implementations for LittleGhost conversations.
5
8
  module SessionStores
@@ -32,6 +35,8 @@ module LittleGhost
32
35
  # stored conversation, then returns the updated snapshot.
33
36
  def append(id, messages:, state:, metadata:, expected_count:, actor_id: nil)
34
37
  messages = persistable_messages(messages)
38
+ state = DataMap.new(state).to_h
39
+ metadata = DataMap.new(metadata).to_h
35
40
  @records_mutex.synchronize do
36
41
  key = id.to_s
37
42
  record = @records[key]
@@ -56,6 +61,8 @@ module LittleGhost
56
61
  # Replaces the complete in-memory snapshot with sanitized +messages+.
57
62
  def replace(id, messages:, state:, metadata:, actor_id: nil)
58
63
  messages = persistable_messages(messages)
64
+ state = DataMap.new(state).to_h
65
+ metadata = DataMap.new(metadata).to_h
59
66
  @records_mutex.synchronize do
60
67
  key = id.to_s
61
68
  validate_actor!(@records[key], actor_id)
@@ -21,11 +21,11 @@ module LittleGhost
21
21
  # end
22
22
  #
23
23
  # LittleGhost then gives +CustomerSupportAgent+ tools to spawn, message, wait for,
24
- # interrupt, and list research agents. The manager keeps each child identity
24
+ # interject, and list research agents. The manager keeps each child identity
25
25
  # stable across follow-up turns.
26
26
  #
27
- # Follow-up messages are FIFO turns and never interrupt active work.
28
- # #interrupt is the separate synchronous path for delivery at the next model
27
+ # Follow-up messages are FIFO turns and never interject active work.
28
+ # #interject is the separate synchronous path for delivery at the next model
29
29
  # boundary; delivery does not stop the child, and tool calls from that model
30
30
  # response continue in the child run.
31
31
  #
@@ -58,16 +58,16 @@ module LittleGhost
58
58
  CURSOR_MAX_BYTES = 512 # :nodoc:
59
59
  UUID_PATTERN = /\A[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/ # :nodoc:
60
60
 
61
- InterruptExchange = Struct.new(:message, :response, :complete) # :nodoc:
61
+ InterjectionExchange = Struct.new(:message, :response, :complete) # :nodoc:
62
62
  Turn = Struct.new( # :nodoc:
63
63
  :number,
64
64
  :message,
65
65
  :completion,
66
66
  :operation_id,
67
67
  :parent_operation_id,
68
- :interrupts,
69
- :interruption_metadata,
70
- :interruption_ids
68
+ :interjections,
69
+ :interjection_metadata,
70
+ :interjection_ids
71
71
  )
72
72
  Identity = Struct.new( # :nodoc:
73
73
  :subagent_id,
@@ -368,7 +368,7 @@ module LittleGhost
368
368
  # model response. The returned +response_disposition+ says whether that
369
369
  # response also initiated tool calls; it does not imply the subagent has
370
370
  # stopped.
371
- def interrupt(subagent_id:, message:, cancellation_token: @cancellation_token, deadline: @deadline)
371
+ def interject(subagent_id:, message:, cancellation_token: @cancellation_token, deadline: @deadline)
372
372
  unless message.is_a?(String)
373
373
  raise ToolError, "Subagent messages must be strings."
374
374
  end
@@ -376,30 +376,30 @@ module LittleGhost
376
376
  raise ToolError, "Subagent messages cannot exceed #{@max_message_chars} characters."
377
377
  end
378
378
 
379
- exchange = InterruptExchange.new(message:, complete: false)
379
+ exchange = InterjectionExchange.new(message:, complete: false)
380
380
  identity, turn = @mutex.synchronize do
381
381
  ensure_open!
382
382
  value = fetch_identity!(subagent_id)
383
- unless value.agent.respond_to?(:interrupt_response)
384
- raise ToolError, "Subagent #{subagent_id.inspect} does not support interruptions."
383
+ unless value.agent.respond_to?(:interject)
384
+ raise ToolError, "Subagent #{subagent_id.inspect} does not support interjections."
385
385
  end
386
386
  unless value.status == "running"
387
387
  raise ToolError, "Subagent #{subagent_id.inspect} is not currently running."
388
388
  end
389
- if value.current.interrupts.length >= @max_queued_turns_per_identity
390
- raise ToolError, "Subagent #{subagent_id.inspect} has reached its interrupt limit."
389
+ if value.current.interjections.length >= @max_queued_turns_per_identity
390
+ raise ToolError, "Subagent #{subagent_id.inspect} has reached its interject limit."
391
391
  end
392
- interrupt_chars = value.current.interrupts.sum { |pending| pending.message.length }
393
- if interrupt_chars + message.length > @max_message_chars
394
- raise ToolError, "Subagent interrupt messages cannot exceed #{@max_message_chars} total characters."
392
+ interjection_chars = value.current.interjections.sum { |pending| pending.message.length }
393
+ if interjection_chars + message.length > @max_message_chars
394
+ raise ToolError, "Subagent interjection messages cannot exceed #{@max_message_chars} total characters."
395
395
  end
396
396
 
397
- value.current.interrupts << exchange
397
+ value.current.interjections << exchange
398
398
  [value, value.current]
399
399
  end
400
400
 
401
- interrupt_response = begin
402
- identity.agent.interrupt_response(
401
+ result = begin
402
+ identity.agent.interject(
403
403
  message,
404
404
  cancellation_token:,
405
405
  deadline:,
@@ -407,16 +407,16 @@ module LittleGhost
407
407
  )
408
408
  rescue
409
409
  @mutex.synchronize do
410
- turn.interrupts.delete(exchange)
410
+ turn.interjections.delete(exchange)
411
411
  @condition.broadcast
412
412
  end
413
413
  raise
414
414
  end
415
- response = interrupt_response.text
415
+ response = result.text
416
416
  truncated = response.length > @max_response_chars
417
417
  returned_response = truncated ? response[0, @max_response_chars] : response
418
418
  @mutex.synchronize do
419
- used_response_chars = turn.interrupts.sum do |pending|
419
+ used_response_chars = turn.interjections.sum do |pending|
420
420
  pending.equal?(exchange) ? 0 : pending.response.to_s.length
421
421
  end
422
422
  remaining_response_chars = [@max_response_chars - used_response_chars, 0].max
@@ -428,17 +428,17 @@ module LittleGhost
428
428
  snapshot(identity, include_response: true, include_progress: true)
429
429
  end
430
430
  value = {
431
- status: "interruption_delivered",
431
+ status: "interjection_delivered",
432
432
  subagent_id: identity.subagent_id,
433
433
  kind: identity.definition.kind,
434
434
  subagent:,
435
435
  turn: turn.number,
436
436
  response: returned_response,
437
- response_disposition: interrupt_response.tool_calls? ? "text_with_tool_calls" : "text_only"
437
+ response_disposition: result.tool_calls? ? "text_with_tool_calls" : "text_only"
438
438
  }
439
439
  value[:response_truncated] = true if truncated
440
440
  value
441
- rescue AgentInterruptError => error
441
+ rescue AgentInterjectionError => error
442
442
  raise ToolError, error.message
443
443
  end
444
444
 
@@ -504,7 +504,7 @@ module LittleGhost
504
504
  end
505
505
  end
506
506
 
507
- # Builds spawn, follow-up, interrupt, wait, and list tools bound to this
507
+ # Builds spawn, follow-up, interject, wait, and list tools bound to this
508
508
  # manager. Closing the first tool closes the shared manager.
509
509
  def tools
510
510
  manager = self
@@ -559,8 +559,8 @@ module LittleGhost
559
559
  description: <<~DESCRIPTION.strip,
560
560
  Send a follow-up turn to an existing active or persisted subagent identity. Persisted conversations
561
561
  are restored transparently before the follow-up. Messages are processed in order after the
562
- current turn and never interrupt active work. Do not use this for status, steering, stopping, or
563
- finalization; use interrupt_subagent for an active subagent. Mode controls delivery: sync waits for the
562
+ current turn and never interject active work. Do not use this for status, steering, stopping, or
563
+ finalization; use interject_subagent for an active subagent. Mode controls delivery: sync waits for the
564
564
  later turn's response, while async enqueues the turn and returns immediately.
565
565
  DESCRIPTION
566
566
  input_schema: {
@@ -586,7 +586,7 @@ module LittleGhost
586
586
  )
587
587
  end,
588
588
  Tool.define(
589
- name: "interrupt_subagent",
589
+ name: "interject_subagent",
590
590
  description: <<~DESCRIPTION.strip,
591
591
  Interrupt an actively running subagent in its current turn. The message is added at the next model
592
592
  boundary. This call waits for that model response and reports its ordinary text, whether the same
@@ -606,7 +606,7 @@ module LittleGhost
606
606
  options = {}
607
607
  options[:cancellation_token] = context.cancellation_token if context
608
608
  options[:deadline] = context.deadline if context&.deadline
609
- manager.interrupt(
609
+ manager.interject(
610
610
  subagent_id: input.fetch("subagent_id"),
611
611
  message: input.fetch("message"),
612
612
  **options
@@ -1089,9 +1089,9 @@ module LittleGhost
1089
1089
  completion: Completion.new,
1090
1090
  operation_id: SecureRandom.uuid,
1091
1091
  parent_operation_id:,
1092
- interrupts: [],
1093
- interruption_metadata: context&.interruption_metadata,
1094
- interruption_ids: context&.interruption_ids || []
1092
+ interjections: [],
1093
+ interjection_metadata: context&.interjection_metadata,
1094
+ interjection_ids: context&.interjection_ids || []
1095
1095
  )
1096
1096
  identity.next_turn += 1
1097
1097
  @turn_count += 1 if count_turn
@@ -1157,8 +1157,8 @@ module LittleGhost
1157
1157
  options[:history] = identity.history
1158
1158
  options[:context] = identity.state
1159
1159
  options[:conversation_id] = identity.conversation_id
1160
- options[:interruption_metadata] = turn.interruption_metadata
1161
- options[:interruption_ids] = turn.interruption_ids
1160
+ options[:interjection_metadata] = turn.interjection_metadata
1161
+ options[:interjection_ids] = turn.interjection_ids
1162
1162
  end
1163
1163
  result = if identity.agent.is_a?(Agent)
1164
1164
  run_agent_turn(identity, turn, options)
@@ -1284,8 +1284,8 @@ module LittleGhost
1284
1284
  response = serialized_response[0, @max_response_chars] if truncated
1285
1285
  persisted_response = response.is_a?(String) ? response : serialized_response
1286
1286
 
1287
- interrupt_exchanges = @mutex.synchronize do
1288
- while identity.durable && turn.interrupts.any? { |exchange| !exchange.complete } && !@closed
1287
+ interject_exchanges = @mutex.synchronize do
1288
+ while identity.durable && turn.interjections.any? { |exchange| !exchange.complete } && !@closed
1289
1289
  @condition.wait(@mutex, CANCELLATION_POLL_INTERVAL)
1290
1290
  end
1291
1291
  if @closed
@@ -1294,11 +1294,11 @@ module LittleGhost
1294
1294
  end
1295
1295
 
1296
1296
  identity.status = "persisting" if identity.durable
1297
- turn.interrupts.select(&:complete).map { |exchange| [exchange.message, exchange.response] }
1297
+ turn.interjections.select(&:complete).map { |exchange| [exchange.message, exchange.response] }
1298
1298
  end
1299
- return unless interrupt_exchanges
1299
+ return unless interject_exchanges
1300
1300
 
1301
- retain_agent_conversation(identity, turn, result, persisted_response, interrupt_exchanges)
1301
+ retain_agent_conversation(identity, turn, result, persisted_response, interject_exchanges)
1302
1302
 
1303
1303
  @mutex.synchronize do
1304
1304
  identity.latest_turn = turn.number
@@ -1323,11 +1323,11 @@ module LittleGhost
1323
1323
  end
1324
1324
  end
1325
1325
 
1326
- def retain_agent_conversation(identity, turn, result, persisted_response, interrupt_exchanges)
1326
+ def retain_agent_conversation(identity, turn, result, persisted_response, interject_exchanges)
1327
1327
  if identity.durable
1328
1328
  state = result.is_a?(RunResult) ? result.state : identity.state
1329
1329
  messages = [Message.new(role: :user, content: turn.message)]
1330
- interrupt_exchanges.each do |message, response|
1330
+ interject_exchanges.each do |message, response|
1331
1331
  messages << Message.new(role: :user, content: message)
1332
1332
  messages << Message.new(role: :assistant, content: response)
1333
1333
  end
@@ -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