little_ghost 0.4.0 → 0.5.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 (112) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +28 -6
  3. data/docs/guides/assemblies.md +143 -17
  4. data/docs/guides/code_mode.md +275 -0
  5. data/docs/guides/core_concepts.md +46 -30
  6. data/docs/guides/getting_started.md +17 -6
  7. data/docs/guides/integrations.md +217 -0
  8. data/docs/guides/models_and_providers.md +125 -0
  9. data/docs/guides/production.md +81 -15
  10. data/docs/guides/prompt_views.md +14 -7
  11. data/docs/guides/sandboxing.md +282 -0
  12. data/docs/guides/skills.md +141 -0
  13. data/docs/guides/structured_outputs_and_content.md +135 -0
  14. data/docs/guides/tools.md +329 -0
  15. data/lib/little_ghost/ag_ui/adapter.rb +2 -2
  16. data/lib/little_ghost/agent/delegation.rb +2 -2
  17. data/lib/little_ghost/agent/skills.rb +6 -1
  18. data/lib/little_ghost/agent/tool_loop.rb +6 -2
  19. data/lib/little_ghost/agent.rb +248 -46
  20. data/lib/little_ghost/agent_builder.rb +61 -22
  21. data/lib/little_ghost/agent_stream_source.rb +262 -0
  22. data/lib/little_ghost/artifact.rb +182 -0
  23. data/lib/little_ghost/artifacts/presentation_budget.rb +44 -0
  24. data/lib/little_ghost/artifacts/workspace_store.rb +370 -0
  25. data/lib/little_ghost/artifacts.rb +11 -0
  26. data/lib/little_ghost/assembly.rb +63 -17
  27. data/lib/little_ghost/assembly_builder.rb +26 -26
  28. data/lib/little_ghost/assembly_execution.rb +23 -2
  29. data/lib/little_ghost/code_mode/broker.rb +164 -0
  30. data/lib/little_ghost/code_mode/catalog.rb +54 -0
  31. data/lib/little_ghost/code_mode/engine.rb +58 -0
  32. data/lib/little_ghost/code_mode/javascript/catalog.rb +118 -0
  33. data/lib/little_ghost/code_mode/javascript/client.rb +550 -0
  34. data/lib/little_ghost/code_mode/javascript/host.rb +347 -0
  35. data/lib/little_ghost/code_mode/javascript/session.rb +579 -0
  36. data/lib/little_ghost/code_mode/javascript_engine.rb +172 -0
  37. data/lib/little_ghost/code_mode/protocol.rb +78 -0
  38. data/lib/little_ghost/code_mode/ruby/catalog.rb +80 -0
  39. data/lib/little_ghost/code_mode/ruby/host.rb +102 -0
  40. data/lib/little_ghost/code_mode/ruby/session.rb +508 -0
  41. data/lib/little_ghost/code_mode/ruby_engine.rb +86 -0
  42. data/lib/little_ghost/code_mode/runtime.rb +335 -0
  43. data/lib/little_ghost/code_mode/session.rb +61 -0
  44. data/lib/little_ghost/code_mode/types.rb +58 -0
  45. data/lib/little_ghost/code_mode.rb +53 -0
  46. data/lib/little_ghost/configuration.rb +107 -12
  47. data/lib/little_ghost/content.rb +19 -8
  48. data/lib/little_ghost/data_map.rb +2 -2
  49. data/lib/little_ghost/errors.rb +26 -7
  50. data/lib/little_ghost/execution.rb +1 -2
  51. data/lib/little_ghost/graph.rb +367 -207
  52. data/lib/little_ghost/mcp/client.rb +487 -88
  53. data/lib/little_ghost/mcp/toolset.rb +210 -0
  54. data/lib/little_ghost/mcp/types.rb +216 -0
  55. data/lib/little_ghost/mcp.rb +3 -0
  56. data/lib/little_ghost/model_capabilities.rb +9 -6
  57. data/lib/little_ghost/model_request.rb +0 -12
  58. data/lib/little_ghost/model_resolver.rb +13 -3
  59. data/lib/little_ghost/model_response.rb +3 -7
  60. data/lib/little_ghost/network/authorizer_server.rb +162 -0
  61. data/lib/little_ghost/network/certificate_authority.rb +98 -0
  62. data/lib/little_ghost/network/envoy_config.rb +362 -0
  63. data/lib/little_ghost/network/envoy_gateway.rb +409 -0
  64. data/lib/little_ghost/network/external_gateway.rb +68 -0
  65. data/lib/little_ghost/network.rb +96 -0
  66. data/lib/little_ghost/prompt_resolver.rb +7 -7
  67. data/lib/little_ghost/provider_registry.rb +3 -3
  68. data/lib/little_ghost/providers/anthropic.rb +8 -1
  69. data/lib/little_ghost/providers/gemini.rb +10 -1
  70. data/lib/little_ghost/providers/vertex_ai.rb +6 -1
  71. data/lib/little_ghost/run.rb +83 -13
  72. data/lib/little_ghost/run_context.rb +9 -0
  73. data/lib/little_ghost/run_result.rb +0 -7
  74. data/lib/little_ghost/runtime/hook.rb +5 -0
  75. data/lib/little_ghost/runtime/hooks/artifacts.rb +338 -0
  76. data/lib/little_ghost/runtime.rb +102 -34
  77. data/lib/little_ghost/sandbox/capabilities.rb +68 -0
  78. data/lib/little_ghost/sandbox/environment_policy.rb +41 -0
  79. data/lib/little_ghost/sandbox/filesystem.rb +377 -0
  80. data/lib/little_ghost/sandbox/isolated_backend.rb +163 -0
  81. data/lib/little_ghost/sandbox/limits.rb +48 -0
  82. data/lib/little_ghost/sandbox/mount.rb +129 -0
  83. data/lib/little_ghost/sandbox/network_policy.rb +108 -0
  84. data/lib/little_ghost/sandbox/policy.rb +88 -0
  85. data/lib/little_ghost/sandbox/process_runner.rb +103 -0
  86. data/lib/little_ghost/sandbox/process_session.rb +335 -0
  87. data/lib/little_ghost/sandbox/scope.rb +304 -0
  88. data/lib/little_ghost/sandbox.rb +158 -32
  89. data/lib/little_ghost/sandboxes/bubblewrap.rb +351 -0
  90. data/lib/little_ghost/sandboxes/native.rb +51 -0
  91. data/lib/little_ghost/sandboxes/seatbelt.rb +261 -0
  92. data/lib/little_ghost/sandboxes/unrestricted.rb +241 -0
  93. data/lib/little_ghost/session.rb +30 -6
  94. data/lib/little_ghost/skills/catalog.rb +94 -14
  95. data/lib/little_ghost/skills/resource_root.rb +42 -0
  96. data/lib/little_ghost/skills/skill.rb +0 -3
  97. data/lib/little_ghost/stream_event.rb +8 -13
  98. data/lib/little_ghost/subagents/control_tool.rb +8 -0
  99. data/lib/little_ghost/subagents/manager.rb +12 -9
  100. data/lib/little_ghost/support/callbacks.rb +3 -1
  101. data/lib/little_ghost/support/content_capture.rb +3 -3
  102. data/lib/little_ghost/support/http_client.rb +2 -2
  103. data/lib/little_ghost/support/redactor.rb +1 -1
  104. data/lib/little_ghost/tool.rb +104 -53
  105. data/lib/little_ghost/tool_registry.rb +1 -1
  106. data/lib/little_ghost/tools/filesystem.rb +1 -1
  107. data/lib/little_ghost/tools/shell.rb +4 -3
  108. data/lib/little_ghost/version.rb +1 -1
  109. data/lib/little_ghost/workspace.rb +222 -8
  110. data/lib/little_ghost.rb +21 -12
  111. metadata +98 -2
  112. data/lib/little_ghost/unrestricted_sandbox.rb +0 -306
@@ -0,0 +1,370 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fiddle"
5
+ require "json"
6
+ require "securerandom"
7
+
8
+ module LittleGhost
9
+ module Artifacts
10
+ # Writes immutable artifacts beneath one named Workspace path. The store
11
+ # applies per-artifact, total-byte, and count limits before exposing a file.
12
+ # It does not remove files; the Workspace provider owns their lifetime.
13
+ class WorkspaceStore # :nodoc:
14
+ ContextControl = Data.define(:cancellation_tokens, :deadlines) do # :nodoc:
15
+ def check!
16
+ cancelled = cancellation_tokens.any? do |token|
17
+ Support::CancellationToken.instance_method(:cancelled?).bind_call(token)
18
+ end
19
+ raise CancelledError, "The run was cancelled" if cancelled
20
+ if deadlines.any? { |deadline| Time.now.to_f >= deadline }
21
+ raise DeadlineExceededError, "The run deadline was reached"
22
+ end
23
+ end
24
+ end
25
+
26
+ PLATFORM_OPEN_FLAGS = case RUBY_PLATFORM
27
+ when /darwin/
28
+ {close_on_exec: 0x0100_0000}
29
+ when /linux/
30
+ {close_on_exec: 0x0008_0000}
31
+ end&.freeze # :nodoc:
32
+ OPENAT = if PLATFORM_OPEN_FLAGS # :nodoc:
33
+ Fiddle::Function.new(
34
+ Fiddle::Handle::DEFAULT["openat"],
35
+ [Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT, Fiddle::TYPE_VARIADIC],
36
+ Fiddle::TYPE_INT
37
+ )
38
+ end
39
+ UNLINKAT = if PLATFORM_OPEN_FLAGS # :nodoc:
40
+ Fiddle::Function.new(
41
+ Fiddle::Handle::DEFAULT["unlinkat"],
42
+ [Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT],
43
+ Fiddle::TYPE_INT
44
+ )
45
+ end
46
+
47
+ # Default maximum size of one artifact: 32 MiB.
48
+ DEFAULT_MAX_ARTIFACT_BYTES = 32 * 1024 * 1024
49
+ # Default cumulative store budget: 64 MiB.
50
+ DEFAULT_MAX_TOTAL_BYTES = 64 * 1024 * 1024
51
+ # Default maximum number of unique artifacts.
52
+ DEFAULT_MAX_ARTIFACTS = 100
53
+
54
+ # Uses the already-open +workspace+ and a configured named +path+.
55
+ def initialize(
56
+ workspace:,
57
+ path: :artifacts,
58
+ max_artifact_bytes: DEFAULT_MAX_ARTIFACT_BYTES,
59
+ max_total_bytes: DEFAULT_MAX_TOTAL_BYTES,
60
+ max_artifacts: DEFAULT_MAX_ARTIFACTS
61
+ )
62
+ raise ToolError, "Artifact workspace path is unavailable" unless workspace
63
+
64
+ @workspace = workspace
65
+ @path = path.to_sym
66
+ @max_artifact_bytes = positive_integer(max_artifact_bytes, :max_artifact_bytes)
67
+ @max_total_bytes = positive_integer(max_total_bytes, :max_total_bytes)
68
+ @max_artifacts = positive_integer(max_artifacts, :max_artifacts)
69
+ @mutex = Mutex.new
70
+ @artifacts = {}
71
+ @total_bytes = 0
72
+ @namespace = SecureRandom.hex(8).freeze
73
+ @root, @root_identity = validate_root!
74
+ end
75
+
76
+ # Materializes +data+ once for an identical name, media type, and byte
77
+ # sequence. Concurrent calls share one descriptor and consume one budget.
78
+ def write(data:, name: nil, media_type: nil, metadata: {}, context: nil)
79
+ write_batch([{data:, name:, media_type:, metadata:}], context:).fetch(0)
80
+ end
81
+
82
+ # Atomically materializes an Array of write keyword Hashes. If any new
83
+ # artifact fails, every file and budget reservation created by this call
84
+ # is rolled back; previously cached artifacts remain unchanged.
85
+ def write_batch(entries, context: nil)
86
+ raise ArgumentError, "artifact batch must be an array" unless entries.is_a?(Array)
87
+
88
+ context&.check!
89
+ control = context_control(context)
90
+ prepared = entries.map { |entry| prepare_entry(entry) }
91
+ @mutex.synchronize do
92
+ control&.check!
93
+ created = []
94
+ begin
95
+ prepared.map do |entry|
96
+ control&.check!
97
+ digest = entry.fetch(:digest)
98
+ next @artifacts.fetch(digest) if @artifacts.key?(digest)
99
+
100
+ reserve!(entry.fetch(:data).bytesize)
101
+ record = entry.merge(materialized: false)
102
+ created << record
103
+ artifact = materialize(**entry, control:)
104
+ @artifacts[digest] = artifact
105
+ record[:materialized] = true
106
+ artifact
107
+ end.freeze
108
+ rescue => error
109
+ rollback_batch(created, error:)
110
+ raise
111
+ end
112
+ end
113
+ end
114
+
115
+ # Number of bytes reserved by unique artifacts in this store.
116
+ def total_bytes = @mutex.synchronize { @total_bytes }
117
+
118
+ # Number of unique artifacts in this store.
119
+ def size = @mutex.synchronize { @artifacts.length }
120
+
121
+ private
122
+
123
+ def prepare_entry(entry)
124
+ raise ArgumentError, "artifact batch entries must be hashes" unless entry.is_a?(Hash)
125
+
126
+ data = String(entry.fetch(:data)).b
127
+ if data.bytesize > @max_artifact_bytes
128
+ raise ToolError, "Artifact exceeds the #{@max_artifact_bytes}-byte limit"
129
+ end
130
+ media_type = entry[:media_type]
131
+ name = normalized_name(entry[:name], media_type:)
132
+ metadata = DataMap.new(entry.fetch(:metadata, {}))
133
+ digest = Digest::SHA256.hexdigest(
134
+ [name, media_type, JSON.generate(metadata), data].join("\0")
135
+ )
136
+ {
137
+ data:,
138
+ digest:,
139
+ file_name: "#{@namespace}-#{digest}-#{name}",
140
+ name:,
141
+ media_type:,
142
+ metadata:
143
+ }
144
+ end
145
+
146
+ def context_control(context)
147
+ values = if context.instance_of?(RunContext)
148
+ {
149
+ cancellation_tokens: [context.cancellation_token],
150
+ deadlines: [context.deadline]
151
+ }
152
+ elsif context&.respond_to?(:artifact_control_values)
153
+ context.artifact_control_values
154
+ else
155
+ return
156
+ end
157
+ tokens = Array(values.fetch(:cancellation_tokens)).select do |token|
158
+ token.instance_of?(Support::CancellationToken)
159
+ end.freeze
160
+ deadlines = Array(values.fetch(:deadlines)).compact.filter_map do |deadline|
161
+ deadline.to_f if deadline.instance_of?(Time)
162
+ end.freeze
163
+ ContextControl.new(cancellation_tokens: tokens, deadlines: deadlines)
164
+ rescue KeyError, NoMethodError, TypeError
165
+ nil
166
+ end
167
+
168
+ def reserve!(bytes)
169
+ raise ToolError, "Artifact count exceeds the #{@max_artifacts}-item limit" if @artifacts.length >= @max_artifacts
170
+ if @total_bytes + bytes > @max_total_bytes
171
+ raise ToolError, "Artifacts exceed the #{@max_total_bytes}-byte total limit"
172
+ end
173
+
174
+ @total_bytes += bytes
175
+ end
176
+
177
+ def materialize(data:, digest:, file_name:, name:, media_type:, metadata:, control:)
178
+ directory = open_root
179
+ path = File.join(@root, file_name)
180
+ created = false
181
+ descriptor = open_at(directory.fileno, file_name, write_flags, 0o600)
182
+ created = true
183
+ file = file_for_descriptor(descriptor, "w")
184
+ begin
185
+ file.binmode
186
+ file.write(data)
187
+ file.flush
188
+ file.chmod(0o600)
189
+ validate_regular_file!(file)
190
+ ensure
191
+ file.close
192
+ end
193
+ control&.check!
194
+ @workspace.validate!
195
+ Artifact.materialized(
196
+ reference: @workspace.reference(path),
197
+ name:,
198
+ media_type:,
199
+ bytes: data.bytesize,
200
+ metadata:
201
+ )
202
+ rescue Errno::EEXIST
203
+ raise ToolError, "Artifact destination already exists"
204
+ rescue Errno::ELOOP
205
+ raise ToolError, "Artifact destination is unsafe"
206
+ rescue => error
207
+ cleanup_created_file(directory, file_name, error:) if created
208
+ raise
209
+ ensure
210
+ directory&.close
211
+ end
212
+
213
+ def rollback_batch(records, error:)
214
+ cleanup_errors = []
215
+ records.reverse_each do |record|
216
+ unless record.fetch(:materialized)
217
+ @total_bytes -= record.fetch(:data).bytesize
218
+ next
219
+ end
220
+
221
+ begin
222
+ unlink_entry(record.fetch(:file_name))
223
+ @artifacts.delete(record.fetch(:digest))
224
+ @total_bytes -= record.fetch(:data).bytesize
225
+ rescue => cleanup_error
226
+ cleanup_errors << cleanup_error
227
+ end
228
+ end
229
+ return if cleanup_errors.empty?
230
+
231
+ raise CleanupError,
232
+ "Artifact batch rollback failed after #{error.class} (#{cleanup_errors.first.class})"
233
+ end
234
+
235
+ def unlink_entry(entry)
236
+ directory = open_root
237
+ unlink_at(directory.fileno, entry)
238
+ ensure
239
+ directory&.close
240
+ end
241
+
242
+ def validate_root!
243
+ unless OPENAT && UNLINKAT
244
+ raise ToolError, "Secure artifact storage is unavailable on this platform"
245
+ end
246
+
247
+ @workspace.validate!
248
+ root = @workspace.path(@path)
249
+ stat = File.lstat(root)
250
+ real = File.realpath(root)
251
+ workspace_real = File.realpath(@workspace.root)
252
+ location_safe = !beneath?(root, @workspace.root) || beneath?(real, workspace_real)
253
+ unless stat.directory? && !stat.symlink? && location_safe
254
+ raise ToolError, "Artifact workspace path is unsafe"
255
+ end
256
+
257
+ directory = File.open(root, directory_flags)
258
+ descriptor_stat = directory.stat
259
+ unless descriptor_stat.directory?
260
+ raise ToolError, "Artifact workspace path is unsafe"
261
+ end
262
+ @workspace.validate!
263
+ [root.freeze, [descriptor_stat.dev, descriptor_stat.ino].freeze].freeze
264
+ rescue KeyError, Errno::ENOENT, Errno::ENOTDIR
265
+ raise ToolError, "Artifact workspace path is unavailable"
266
+ rescue SystemCallError
267
+ raise ToolError, "Artifact workspace path is unavailable"
268
+ ensure
269
+ directory&.close
270
+ end
271
+
272
+ def open_root
273
+ @workspace.validate!
274
+ directory = File.open(@root, directory_flags)
275
+ stat = directory.stat
276
+ unless stat.directory? && [stat.dev, stat.ino] == @root_identity
277
+ raise ToolError, "Artifact workspace path changed after initialization"
278
+ end
279
+ @workspace.validate!
280
+ directory
281
+ rescue Errno::ENOENT, Errno::ELOOP, Errno::ENOTDIR
282
+ directory&.close
283
+ raise ToolError, "Artifact workspace path changed after initialization"
284
+ rescue
285
+ directory&.close
286
+ raise
287
+ end
288
+
289
+ def open_at(directory, entry, flags, permissions)
290
+ descriptor = OPENAT.call(directory, entry, flags, Fiddle::TYPE_UINT, permissions)
291
+ raise SystemCallError.new("openat", Fiddle.last_error) if descriptor.negative?
292
+
293
+ descriptor
294
+ end
295
+
296
+ def unlink_at(directory, entry)
297
+ result = UNLINKAT.call(directory, entry, 0)
298
+ raise SystemCallError.new("unlinkat", Fiddle.last_error) if result.negative?
299
+ end
300
+
301
+ def file_for_descriptor(descriptor, mode)
302
+ File.for_fd(descriptor, mode, autoclose: true)
303
+ rescue
304
+ IO.for_fd(descriptor).close
305
+ raise
306
+ end
307
+
308
+ def validate_regular_file!(file)
309
+ stat = file.stat
310
+ unless stat.file? && stat.nlink == 1 && (stat.mode & 0o777) == 0o600
311
+ raise ToolError, "Artifact destination is unsafe"
312
+ end
313
+ end
314
+
315
+ def cleanup_created_file(directory, entry, error:)
316
+ unlink_at(directory.fileno, entry)
317
+ rescue => cleanup_error
318
+ raise CleanupError,
319
+ "Artifact cleanup failed after #{error.class} (#{cleanup_error.class})"
320
+ end
321
+
322
+ def write_flags
323
+ flags = File::WRONLY | File::CREAT | File::EXCL
324
+ flags |= File::NOFOLLOW if defined?(File::NOFOLLOW)
325
+ flags |= File::NONBLOCK if defined?(File::NONBLOCK)
326
+ flags |= PLATFORM_OPEN_FLAGS.fetch(:close_on_exec)
327
+ flags
328
+ end
329
+
330
+ def directory_flags
331
+ flags = File::RDONLY
332
+ flags |= File::NOFOLLOW if defined?(File::NOFOLLOW)
333
+ flags |= File::NONBLOCK if defined?(File::NONBLOCK)
334
+ flags |= PLATFORM_OPEN_FLAGS.fetch(:close_on_exec)
335
+ flags
336
+ end
337
+
338
+ def normalized_name(name, media_type:)
339
+ value = name.to_s
340
+ value = default_name(media_type) if value.empty?
341
+ value = File.basename(value).scrub.gsub(/[^A-Za-z0-9._-]/, "_")
342
+ value = "artifact" if value.empty? || value == "." || value == ".."
343
+ value.byteslice(0, 120)
344
+ end
345
+
346
+ def default_name(media_type)
347
+ extension = {
348
+ "application/json" => ".json",
349
+ "text/plain" => ".txt",
350
+ "text/markdown" => ".md",
351
+ "image/png" => ".png",
352
+ "image/jpeg" => ".jpg",
353
+ "application/pdf" => ".pdf"
354
+ }[media_type.to_s]
355
+ "artifact#{extension}"
356
+ end
357
+
358
+ def beneath?(candidate, base)
359
+ candidate == base || candidate.start_with?(base.end_with?(File::SEPARATOR) ? base : "#{base}#{File::SEPARATOR}")
360
+ end
361
+
362
+ def positive_integer(value, name)
363
+ integer = Integer(value)
364
+ raise ArgumentError, "#{name} must be positive" unless integer.positive?
365
+
366
+ integer
367
+ end
368
+ end
369
+ end
370
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LittleGhost
4
+ # Stores files from Run input and Tool results, then presents them to agents
5
+ # as bounded images, documents, previews, or Workspace references.
6
+ module Artifacts
7
+ end
8
+ end
9
+
10
+ require_relative "artifacts/workspace_store"
11
+ require_relative "artifacts/presentation_budget"
@@ -14,18 +14,19 @@ module LittleGhost
14
14
  # agent_run.response
15
15
  # graph_run.response
16
16
  #
17
- # Named subclasses are the usual form. +to_builder+ creates a mutable dynamic
18
- # definition seeded by the class, while +definition+ returns the immutable
19
- # snapshot used for one execution. Composite results expose Assembly::Step
20
- # records through RunResult#trajectory.
17
+ # Applications normally subclass Agent, Workflow, Swarm, or Graph rather
18
+ # than Assembly directly. Each standalone call returns a top-level Run.
19
+ # Participants called inside another Assembly return a RunResult to their
20
+ # parent.
21
21
  #
22
- # A standalone assembly owns a top-level Run. An assembly built by a Runtime
23
- # participates in the existing run and returns a RunResult. Applications
24
- # normally subclass Agent, Workflow, Swarm, or Graph rather than Assembly
25
- # directly. Standalone calls automatically reuse the active Configuration's
26
- # shared Runtime while keeping each Run and its resources independent.
22
+ # == Advanced construction
27
23
  #
28
- # == What each calling form returns
24
+ # Named subclasses are the usual form. +to_builder+ creates a mutable
25
+ # definition for applications that discover participants at runtime.
26
+ # +definition+ returns the fixed snapshot used by one execution. Composite
27
+ # results record their steps in RunResult#trajectory.
28
+ #
29
+ # == Return values
29
30
  #
30
31
  # [<tt>CustomerSupportAgent.ask(...)</tt>]
31
32
  # A named class creates and returns a top-level Run.
@@ -56,6 +57,11 @@ module LittleGhost
56
57
  #
57
58
  # Enumeration yields StreamEvent objects and returns the terminal Run.
58
59
  # The same Invocation fields accepted by .ask may be supplied as +options+.
60
+ # Composite assemblies also emit an +:agent_stream+ event for every
61
+ # normalized event from every Agent in the run, including intermediate
62
+ # and nested participants. Set <tt>include_agent_events: false</tt> to
63
+ # keep only the ordinary public stream. A standalone Agent retains its
64
+ # ordinary stream by default and accepts +true+ to opt in.
59
65
  def stream_ask(message, **options)
60
66
  snapshot = definition
61
67
  stream = nil
@@ -144,6 +150,7 @@ module LittleGhost
144
150
  attr_reader :workspace
145
151
  # Sandbox supplied to this Assembly, when present.
146
152
  attr_reader :sandbox
153
+ attr_reader :agent_stream_path # :nodoc:
147
154
 
148
155
  def initialize(run: nil, runtime: nil, workspace: nil, sandbox: nil, standalone: run.nil?) # :nodoc:
149
156
  @run = run
@@ -154,28 +161,39 @@ module LittleGhost
154
161
  @assembly_mutex = Mutex.new
155
162
  @assembly_closed = false
156
163
  @active_assemblies = []
164
+ @agent_stream_path = [].freeze
165
+ end
166
+
167
+ def bind_agent_stream_path(path) # :nodoc:
168
+ @agent_stream_path = Array(path).dup.freeze
169
+ self
157
170
  end
158
171
 
159
172
  # Builds the top-level Run used by a standalone assembly.
160
- def build_run(payload) # :nodoc:
173
+ def build_run(payload = nil, include_agent_events_by_default: false, **payload_options) # :nodoc:
174
+ payload = entrypoint_payload(payload, payload_options) unless payload_options.empty?
161
175
  payload = payload.dup if payload.is_a?(Hash)
162
176
  cancellation_token = if payload.is_a?(Hash)
163
177
  payload.delete(:cancellation_token) || payload.delete("cancellation_token")
164
178
  end
165
- source_class = self.class.respond_to?(:assembly_source_class) ? self.class.assembly_source_class : self.class
179
+ source_class = run_entrypoint_class
166
180
  options = {entrypoint_class: source_class}
167
181
  options[:execution_class] = self.class unless source_class.equal?(self.class)
168
182
  options[:agent_class] = source_class if is_a?(Agent)
169
183
  options[:cancellation_token] = cancellation_token if cancellation_token
170
184
  options[:workspace] = workspace if workspace
171
185
  options[:sandbox] = sandbox if sandbox
186
+ options[:include_agent_events_by_default] = true if include_agent_events_by_default
172
187
  runtime.build_run(payload, **options)
173
188
  end
174
189
 
175
190
  # Starts +payload+ on a supervised worker and returns an Execution.
191
+ # Composite assemblies include contextual +:agent_stream+ events in the
192
+ # consumer by default. Set +include_agent_events+ to +false+ in +payload+
193
+ # to keep only the ordinary public stream.
176
194
  def start_execution(payload, &event_consumer)
177
195
  ensure_standalone!
178
- Execution.start(build_run(payload), &event_consumer)
196
+ Execution.start(build_stream_run(payload), &event_consumer)
179
197
  end
180
198
 
181
199
  # Runs +input+ to completion.
@@ -204,13 +222,17 @@ module LittleGhost
204
222
  #
205
223
  # A standalone stream returns its terminal Run after enumeration. A
206
224
  # run-scoped stream finishes with an +invocation_stop+ event containing its
207
- # RunResult.
225
+ # RunResult. A standalone composite Assembly receives contextual
226
+ # +:agent_stream+ events from every Agent in the Run by default and may set
227
+ # <tt>include_agent_events: false</tt> to omit them. A standalone Agent may
228
+ # set the option to +true+ to include its contextual wrapper.
208
229
  def stream_ask(message, **options)
209
230
  if standalone?
210
231
  options[:deadline_at] = options.delete(:deadline) if options.key?(:deadline)
232
+ payload = entrypoint_payload(message, options)
211
233
  stream = nil
212
234
  return Enumerator.new do |events|
213
- stream ||= build_run(entrypoint_payload(message, options)).each
235
+ stream ||= build_stream_run(payload).each
214
236
  stream.each { |event| events << event }
215
237
  end
216
238
  end
@@ -245,7 +267,7 @@ module LittleGhost
245
267
  target = if assembly.is_a?(Agent)
246
268
  assembly
247
269
  elsif assembly.run
248
- assembly.runtime.build_assembly(assembly.class, run: assembly.run)
270
+ assembly.send(:build_tool_assembly)
249
271
  else
250
272
  assembly.class.new(runtime: assembly.runtime)
251
273
  end
@@ -254,7 +276,7 @@ module LittleGhost
254
276
  context: context&.state || {},
255
277
  cancellation_token: context&.cancellation_token || Support::CancellationToken.new,
256
278
  deadline: context&.deadline,
257
- parent_operation_id: assembly.run&.operation_id
279
+ parent_operation_id: context&.agent_operation_id || assembly.run&.operation_id
258
280
  }
259
281
  if target.is_a?(Agent)
260
282
  options[:interjection_metadata] = context&.interjection_metadata
@@ -338,6 +360,30 @@ module LittleGhost
338
360
 
339
361
  private
340
362
 
363
+ def build_stream_run(payload) # :nodoc:
364
+ return build_run(payload) if is_a?(Agent)
365
+
366
+ build_run(payload, include_agent_events_by_default: true)
367
+ end
368
+
369
+ def run_entrypoint_class # :nodoc:
370
+ return self.class.assembly_source_class if self.class.respond_to?(:assembly_source_class)
371
+
372
+ self.class
373
+ end
374
+
375
+ def build_tool_assembly
376
+ builder = runtime.method(:build_assembly)
377
+ accepts_path = builder.parameters.any? do |kind, name|
378
+ kind == :keyrest || (%i[key keyreq].include?(kind) && name == :agent_stream_path)
379
+ end
380
+ options = {run:}
381
+ options[:agent_stream_path] = agent_stream_path if accepts_path
382
+ child = builder.call(self.class, **options)
383
+ child.bind_agent_stream_path(agent_stream_path) if child.respond_to?(:bind_agent_stream_path)
384
+ child
385
+ end
386
+
341
387
  def ensure_standalone!
342
388
  raise Error, "Only a standalone assembly can start an execution" unless standalone?
343
389
  end