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.
- checksums.yaml +4 -4
- data/README.md +28 -6
- data/docs/guides/assemblies.md +143 -17
- data/docs/guides/code_mode.md +275 -0
- data/docs/guides/core_concepts.md +46 -30
- data/docs/guides/getting_started.md +17 -6
- data/docs/guides/integrations.md +217 -0
- data/docs/guides/models_and_providers.md +125 -0
- data/docs/guides/production.md +81 -15
- data/docs/guides/prompt_views.md +14 -7
- data/docs/guides/sandboxing.md +282 -0
- data/docs/guides/skills.md +141 -0
- data/docs/guides/structured_outputs_and_content.md +135 -0
- data/docs/guides/tools.md +329 -0
- data/lib/little_ghost/ag_ui/adapter.rb +2 -2
- data/lib/little_ghost/agent/delegation.rb +2 -2
- data/lib/little_ghost/agent/skills.rb +6 -1
- data/lib/little_ghost/agent/tool_loop.rb +6 -2
- data/lib/little_ghost/agent.rb +248 -46
- data/lib/little_ghost/agent_builder.rb +61 -22
- data/lib/little_ghost/agent_stream_source.rb +262 -0
- data/lib/little_ghost/artifact.rb +182 -0
- data/lib/little_ghost/artifacts/presentation_budget.rb +44 -0
- data/lib/little_ghost/artifacts/workspace_store.rb +370 -0
- data/lib/little_ghost/artifacts.rb +11 -0
- data/lib/little_ghost/assembly.rb +63 -17
- data/lib/little_ghost/assembly_builder.rb +26 -26
- data/lib/little_ghost/assembly_execution.rb +23 -2
- data/lib/little_ghost/code_mode/broker.rb +164 -0
- data/lib/little_ghost/code_mode/catalog.rb +54 -0
- data/lib/little_ghost/code_mode/engine.rb +58 -0
- data/lib/little_ghost/code_mode/javascript/catalog.rb +118 -0
- data/lib/little_ghost/code_mode/javascript/client.rb +550 -0
- data/lib/little_ghost/code_mode/javascript/host.rb +347 -0
- data/lib/little_ghost/code_mode/javascript/session.rb +579 -0
- data/lib/little_ghost/code_mode/javascript_engine.rb +172 -0
- data/lib/little_ghost/code_mode/protocol.rb +78 -0
- data/lib/little_ghost/code_mode/ruby/catalog.rb +80 -0
- data/lib/little_ghost/code_mode/ruby/host.rb +102 -0
- data/lib/little_ghost/code_mode/ruby/session.rb +508 -0
- data/lib/little_ghost/code_mode/ruby_engine.rb +86 -0
- data/lib/little_ghost/code_mode/runtime.rb +335 -0
- data/lib/little_ghost/code_mode/session.rb +61 -0
- data/lib/little_ghost/code_mode/types.rb +58 -0
- data/lib/little_ghost/code_mode.rb +53 -0
- data/lib/little_ghost/configuration.rb +107 -12
- data/lib/little_ghost/content.rb +19 -8
- data/lib/little_ghost/data_map.rb +2 -2
- data/lib/little_ghost/errors.rb +26 -7
- data/lib/little_ghost/execution.rb +1 -2
- data/lib/little_ghost/graph.rb +367 -207
- data/lib/little_ghost/mcp/client.rb +487 -88
- data/lib/little_ghost/mcp/toolset.rb +210 -0
- data/lib/little_ghost/mcp/types.rb +216 -0
- data/lib/little_ghost/mcp.rb +3 -0
- data/lib/little_ghost/model_capabilities.rb +9 -6
- data/lib/little_ghost/model_request.rb +0 -12
- data/lib/little_ghost/model_resolver.rb +13 -3
- data/lib/little_ghost/model_response.rb +3 -7
- data/lib/little_ghost/network/authorizer_server.rb +162 -0
- data/lib/little_ghost/network/certificate_authority.rb +98 -0
- data/lib/little_ghost/network/envoy_config.rb +362 -0
- data/lib/little_ghost/network/envoy_gateway.rb +409 -0
- data/lib/little_ghost/network/external_gateway.rb +68 -0
- data/lib/little_ghost/network.rb +96 -0
- data/lib/little_ghost/prompt_resolver.rb +7 -7
- data/lib/little_ghost/provider_registry.rb +3 -3
- data/lib/little_ghost/providers/anthropic.rb +8 -1
- data/lib/little_ghost/providers/gemini.rb +10 -1
- data/lib/little_ghost/providers/vertex_ai.rb +6 -1
- data/lib/little_ghost/run.rb +83 -13
- data/lib/little_ghost/run_context.rb +9 -0
- data/lib/little_ghost/run_result.rb +0 -7
- data/lib/little_ghost/runtime/hook.rb +5 -0
- data/lib/little_ghost/runtime/hooks/artifacts.rb +338 -0
- data/lib/little_ghost/runtime.rb +102 -34
- data/lib/little_ghost/sandbox/capabilities.rb +68 -0
- data/lib/little_ghost/sandbox/environment_policy.rb +41 -0
- data/lib/little_ghost/sandbox/filesystem.rb +377 -0
- data/lib/little_ghost/sandbox/isolated_backend.rb +163 -0
- data/lib/little_ghost/sandbox/limits.rb +48 -0
- data/lib/little_ghost/sandbox/mount.rb +129 -0
- data/lib/little_ghost/sandbox/network_policy.rb +108 -0
- data/lib/little_ghost/sandbox/policy.rb +88 -0
- data/lib/little_ghost/sandbox/process_runner.rb +103 -0
- data/lib/little_ghost/sandbox/process_session.rb +335 -0
- data/lib/little_ghost/sandbox/scope.rb +304 -0
- data/lib/little_ghost/sandbox.rb +158 -32
- data/lib/little_ghost/sandboxes/bubblewrap.rb +351 -0
- data/lib/little_ghost/sandboxes/native.rb +51 -0
- data/lib/little_ghost/sandboxes/seatbelt.rb +261 -0
- data/lib/little_ghost/sandboxes/unrestricted.rb +241 -0
- data/lib/little_ghost/session.rb +30 -6
- data/lib/little_ghost/skills/catalog.rb +94 -14
- data/lib/little_ghost/skills/resource_root.rb +42 -0
- data/lib/little_ghost/skills/skill.rb +0 -3
- data/lib/little_ghost/stream_event.rb +8 -13
- data/lib/little_ghost/subagents/control_tool.rb +8 -0
- data/lib/little_ghost/subagents/manager.rb +12 -9
- data/lib/little_ghost/support/callbacks.rb +3 -1
- data/lib/little_ghost/support/content_capture.rb +3 -3
- data/lib/little_ghost/support/http_client.rb +2 -2
- data/lib/little_ghost/support/redactor.rb +1 -1
- data/lib/little_ghost/tool.rb +104 -53
- data/lib/little_ghost/tool_registry.rb +1 -1
- data/lib/little_ghost/tools/filesystem.rb +1 -1
- data/lib/little_ghost/tools/shell.rb +4 -3
- data/lib/little_ghost/version.rb +1 -1
- data/lib/little_ghost/workspace.rb +222 -8
- data/lib/little_ghost.rb +21 -12
- metadata +98 -2
- 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
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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 =
|
|
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(
|
|
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 ||=
|
|
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.
|
|
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
|