little_ghost 0.1.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 +7 -0
- data/LICENSE.txt +22 -0
- data/README.md +122 -0
- data/docs/guides/Core Concepts.md +203 -0
- data/docs/guides/Getting Started.md +187 -0
- data/lib/little_ghost/ag_ui/adapter.rb +194 -0
- data/lib/little_ghost/ag_ui.rb +5 -0
- data/lib/little_ghost/agent/context_management.rb +285 -0
- data/lib/little_ghost/agent/delegation.rb +128 -0
- data/lib/little_ghost/agent/skills.rb +96 -0
- data/lib/little_ghost/agent/tool_loop.rb +239 -0
- data/lib/little_ghost/agent.rb +2111 -0
- data/lib/little_ghost/agent_builder.rb +191 -0
- data/lib/little_ghost/agent_interruptions.rb +197 -0
- data/lib/little_ghost/configuration.rb +337 -0
- data/lib/little_ghost/content.rb +324 -0
- data/lib/little_ghost/default_model_registry.rb +71 -0
- data/lib/little_ghost/errors.rb +48 -0
- data/lib/little_ghost/events.rb +264 -0
- data/lib/little_ghost/execution_state.rb +58 -0
- data/lib/little_ghost/instrumentation.rb +475 -0
- data/lib/little_ghost/invocation.rb +285 -0
- data/lib/little_ghost/lookup.rb +37 -0
- data/lib/little_ghost/mcp/client.rb +396 -0
- data/lib/little_ghost/mcp.rb +5 -0
- data/lib/little_ghost/message.rb +75 -0
- data/lib/little_ghost/model.rb +88 -0
- data/lib/little_ghost/model_capabilities.rb +126 -0
- data/lib/little_ghost/model_registry.rb +173 -0
- data/lib/little_ghost/model_request.rb +107 -0
- data/lib/little_ghost/model_response.rb +48 -0
- data/lib/little_ghost/path_set.rb +32 -0
- data/lib/little_ghost/prompt_resolver.rb +251 -0
- data/lib/little_ghost/providers/bedrock.rb +506 -0
- data/lib/little_ghost/providers/http_transport.rb +149 -0
- data/lib/little_ghost/providers/open_router.rb +171 -0
- data/lib/little_ghost/providers/openai.rb +27 -0
- data/lib/little_ghost/providers/openai_compatible.rb +745 -0
- data/lib/little_ghost/providers/sse_parser.rb +35 -0
- data/lib/little_ghost/run.rb +607 -0
- data/lib/little_ghost/run_context.rb +129 -0
- data/lib/little_ghost/run_result.rb +111 -0
- data/lib/little_ghost/runtime/hook.rb +31 -0
- data/lib/little_ghost/runtime.rb +392 -0
- data/lib/little_ghost/sandbox.rb +138 -0
- data/lib/little_ghost/session.rb +229 -0
- data/lib/little_ghost/session_store.rb +96 -0
- data/lib/little_ghost/session_stores/agent_core_memory.rb +1086 -0
- data/lib/little_ghost/session_stores/memory.rb +86 -0
- data/lib/little_ghost/skills/catalog.rb +283 -0
- data/lib/little_ghost/skills/skill.rb +60 -0
- data/lib/little_ghost/skills.rb +4 -0
- data/lib/little_ghost/stream_event.rb +49 -0
- data/lib/little_ghost/structured_output.rb +126 -0
- data/lib/little_ghost/subagents/agent_path.rb +63 -0
- data/lib/little_ghost/subagents/definition.rb +42 -0
- data/lib/little_ghost/subagents/manager.rb +1615 -0
- data/lib/little_ghost/support/callbacks.rb +151 -0
- data/lib/little_ghost/support/cancellation_token.rb +86 -0
- data/lib/little_ghost/support/class_attributes.rb +40 -0
- data/lib/little_ghost/support/content_capture.rb +150 -0
- data/lib/little_ghost/support/executor.rb +75 -0
- data/lib/little_ghost/support/interruptible_stream.rb +103 -0
- data/lib/little_ghost/support/loader.rb +263 -0
- data/lib/little_ghost/support/output_truncation.rb +71 -0
- data/lib/little_ghost/support/redactor.rb +66 -0
- data/lib/little_ghost/support.rb +34 -0
- data/lib/little_ghost/tool.rb +448 -0
- data/lib/little_ghost/tool_execution.rb +59 -0
- data/lib/little_ghost/tool_registry.rb +156 -0
- data/lib/little_ghost/tools/filesystem.rb +119 -0
- data/lib/little_ghost/tools/shell.rb +45 -0
- data/lib/little_ghost/tools/write_todos.rb +91 -0
- data/lib/little_ghost/tools.rb +6 -0
- data/lib/little_ghost/tracing/open_telemetry.rb +517 -0
- data/lib/little_ghost/unrestricted_sandbox.rb +306 -0
- data/lib/little_ghost/usage.rb +47 -0
- data/lib/little_ghost/version.rb +6 -0
- data/lib/little_ghost/workflow.rb +351 -0
- data/lib/little_ghost/workspace.rb +31 -0
- data/lib/little_ghost.rb +120 -0
- metadata +225 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module LittleGhost
|
|
6
|
+
# UnrestrictedSandbox is a convenient host-backed sandbox for trusted local
|
|
7
|
+
# work. It offers bounded text-file operations and command execution using only
|
|
8
|
+
# Ruby's standard library.
|
|
9
|
+
#
|
|
10
|
+
# workspace = LittleGhost::Workspace.new(root: Dir.pwd)
|
|
11
|
+
# sandbox = LittleGhost::UnrestrictedSandbox.new(workspace:)
|
|
12
|
+
# sandbox.read("README.md").lines.first # => "# LittleGhost\n"
|
|
13
|
+
#
|
|
14
|
+
# Reads return valid UTF-8 text. Writes preserve the supplied String bytes.
|
|
15
|
+
# Paths must be relative, may not contain +..+, and are checked against the
|
|
16
|
+
# configured workspace root.
|
|
17
|
+
#
|
|
18
|
+
# === Security and trust
|
|
19
|
+
#
|
|
20
|
+
# This sandbox is not a security boundary. Commands run directly on the host
|
|
21
|
+
# with the Ruby process's permissions, and filesystem containment cannot defend
|
|
22
|
+
# against concurrent adversarial mutation. Use an isolated Sandbox
|
|
23
|
+
# implementation for untrusted work.
|
|
24
|
+
class UnrestrictedSandbox < Sandbox
|
|
25
|
+
# Configures a host sandbox with explicit read, write, and listing limits.
|
|
26
|
+
# Filesystem writes remain disabled unless +writable+ is true.
|
|
27
|
+
def initialize(workspace:, writable: false, max_read_bytes: 1_000_000, max_write_bytes: 1_000_000, max_list_entries: 10_000)
|
|
28
|
+
super(workspace:)
|
|
29
|
+
@writable = writable
|
|
30
|
+
@max_read_bytes = Integer(max_read_bytes)
|
|
31
|
+
@max_write_bytes = Integer(max_write_bytes)
|
|
32
|
+
@max_list_entries = Integer(max_list_entries)
|
|
33
|
+
unless [@max_read_bytes, @max_write_bytes, @max_list_entries].all?(&:positive?)
|
|
34
|
+
raise ArgumentError, "sandbox limits must be positive"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
@root = File.expand_path(workspace.root)
|
|
38
|
+
capture_root_identity if File.exist?(@root)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Opens the sandbox and verifies that the workspace root has not changed.
|
|
42
|
+
def open(run: nil)
|
|
43
|
+
if @root_identity
|
|
44
|
+
validate_root!
|
|
45
|
+
else
|
|
46
|
+
@root = File.realpath(workspace.root)
|
|
47
|
+
capture_root_identity
|
|
48
|
+
end
|
|
49
|
+
self
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Indicates whether this sandbox accepts filesystem mutations.
|
|
53
|
+
def writable? = @writable
|
|
54
|
+
|
|
55
|
+
# Reads a bounded UTF-8 file within the workspace.
|
|
56
|
+
def read(path, context: nil)
|
|
57
|
+
context&.check!
|
|
58
|
+
File.open(existing_path(path), read_flags) do |file|
|
|
59
|
+
raise ToolError, "Path is not a file" unless file.stat.file?
|
|
60
|
+
|
|
61
|
+
content = file.read(@max_read_bytes + 1)
|
|
62
|
+
raise ToolError, "File exceeds the read limit" if content.bytesize > @max_read_bytes
|
|
63
|
+
|
|
64
|
+
content.force_encoding(Encoding::UTF_8)
|
|
65
|
+
raise ToolError, "File is not valid UTF-8 text" unless content.valid_encoding?
|
|
66
|
+
content
|
|
67
|
+
end
|
|
68
|
+
rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError
|
|
69
|
+
raise ToolError, "File is not valid UTF-8 text"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Produces a newline-delimited, sorted directory listing. Directories end in
|
|
73
|
+
# +/+.
|
|
74
|
+
def list(path = ".", context: nil)
|
|
75
|
+
context&.check!
|
|
76
|
+
directory = existing_path(path, allow_root: true)
|
|
77
|
+
raise ToolError, "Path is not a directory" unless File.directory?(directory)
|
|
78
|
+
|
|
79
|
+
entries = Dir.children(directory)
|
|
80
|
+
raise ToolError, "Directory exceeds the listing limit" if entries.length > @max_list_entries
|
|
81
|
+
|
|
82
|
+
entries.sort.map do |entry|
|
|
83
|
+
File.lstat(File.join(directory, entry)).directory? ? "#{entry}/" : entry
|
|
84
|
+
end.join("\n")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Writes a bounded String without following a symbolic-link target.
|
|
88
|
+
def write(path, content, context: nil)
|
|
89
|
+
context&.check!
|
|
90
|
+
raise ToolError, "Sandbox is read-only" unless writable?
|
|
91
|
+
raise ToolError, "Content exceeds the write limit" if content.bytesize > @max_write_bytes
|
|
92
|
+
|
|
93
|
+
flags = File::WRONLY | File::CREAT | File::TRUNC
|
|
94
|
+
flags |= File::NOFOLLOW if defined?(File::NOFOLLOW)
|
|
95
|
+
flags |= File::NONBLOCK if defined?(File::NONBLOCK)
|
|
96
|
+
File.open(writable_path(path), flags, 0o644) do |file|
|
|
97
|
+
raise ToolError, "Path is not a file" unless file.stat.file?
|
|
98
|
+
|
|
99
|
+
file.write(content)
|
|
100
|
+
end
|
|
101
|
+
"Wrote #{content.bytesize} bytes to #{display_path(path)}"
|
|
102
|
+
rescue Errno::ELOOP
|
|
103
|
+
raise ToolError, "Write target cannot be a symbolic link"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Replaces exactly one occurrence of +old_text+ in a writable file.
|
|
107
|
+
def replace(path, old_text, new_text, context: nil)
|
|
108
|
+
context&.check!
|
|
109
|
+
raise ToolError, "Text to replace cannot be empty" if old_text.empty?
|
|
110
|
+
|
|
111
|
+
content = read(path, context:)
|
|
112
|
+
occurrences = content.scan(old_text).length
|
|
113
|
+
raise ToolError, "Text was not found in #{display_path(path)}" if occurrences.zero?
|
|
114
|
+
raise ToolError, "Text occurs more than once in #{display_path(path)}" if occurrences > 1
|
|
115
|
+
|
|
116
|
+
write(path, content.sub(old_text, new_text), context:)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Executes an argument vector on the host from the workspace root.
|
|
120
|
+
#
|
|
121
|
+
# Shell syntax is not interpreted. The child starts with an empty environment
|
|
122
|
+
# unless +inherit_environment+ is true, is terminated when the context is
|
|
123
|
+
# cancelled or the timeout expires, and has each output stream truncated to
|
|
124
|
+
# +max_output_bytes+.
|
|
125
|
+
def execute_program(
|
|
126
|
+
command,
|
|
127
|
+
timeout:,
|
|
128
|
+
context: nil,
|
|
129
|
+
max_output_bytes: 1_000_000,
|
|
130
|
+
environment: {},
|
|
131
|
+
inherit_environment: false
|
|
132
|
+
)
|
|
133
|
+
argv = Array(command).map(&:to_s)
|
|
134
|
+
raise ToolError, "Command must contain an executable" if argv.empty? || argv.first.empty?
|
|
135
|
+
|
|
136
|
+
timeout = Float(timeout)
|
|
137
|
+
max_output_bytes = Integer(max_output_bytes)
|
|
138
|
+
raise ArgumentError, "timeout must be positive" unless timeout.positive?
|
|
139
|
+
raise ArgumentError, "max_output_bytes must be positive" unless max_output_bytes.positive?
|
|
140
|
+
|
|
141
|
+
stdout, stderr, status = capture(
|
|
142
|
+
argv,
|
|
143
|
+
timeout:,
|
|
144
|
+
context:,
|
|
145
|
+
max_output_bytes:,
|
|
146
|
+
environment:,
|
|
147
|
+
inherit_environment:
|
|
148
|
+
)
|
|
149
|
+
Execution.new(stdout:, stderr:, exit_code: status.exitstatus)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
private
|
|
153
|
+
|
|
154
|
+
def capture(argv, timeout:, context:, max_output_bytes:, environment:, inherit_environment:)
|
|
155
|
+
result = nil
|
|
156
|
+
deadline = monotonic_time + timeout
|
|
157
|
+
Open3.popen3(
|
|
158
|
+
environment.transform_keys(&:to_s).transform_values(&:to_s),
|
|
159
|
+
*argv,
|
|
160
|
+
chdir: workspace.root,
|
|
161
|
+
pgroup: true,
|
|
162
|
+
unsetenv_others: !inherit_environment
|
|
163
|
+
) do |stdin, stdout, stderr, wait_thread|
|
|
164
|
+
stdin.close
|
|
165
|
+
stdout_reader = Thread.new { drain(stdout, max_output_bytes) }
|
|
166
|
+
stderr_reader = Thread.new { drain(stderr, max_output_bytes) }
|
|
167
|
+
wait_for(wait_thread, [stdout_reader, stderr_reader], deadline, context)
|
|
168
|
+
result = [stdout_reader.value, stderr_reader.value, wait_thread.value]
|
|
169
|
+
ensure
|
|
170
|
+
stdout_reader&.kill
|
|
171
|
+
stderr_reader&.kill
|
|
172
|
+
end
|
|
173
|
+
result
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def wait_for(wait_thread, readers, deadline, context)
|
|
177
|
+
until !wait_thread.alive? && readers.none?(&:alive?)
|
|
178
|
+
context&.check!
|
|
179
|
+
raise ToolError, "Command timed out" if monotonic_time >= deadline
|
|
180
|
+
|
|
181
|
+
wait_thread.join(0.01)
|
|
182
|
+
Thread.pass
|
|
183
|
+
end
|
|
184
|
+
rescue
|
|
185
|
+
terminate(wait_thread.pid)
|
|
186
|
+
raise
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def terminate(pid)
|
|
190
|
+
Process.kill("TERM", -pid)
|
|
191
|
+
deadline = monotonic_time + 0.5
|
|
192
|
+
while monotonic_time < deadline
|
|
193
|
+
return unless process_group_alive?(pid)
|
|
194
|
+
|
|
195
|
+
Thread.pass
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
Process.kill("KILL", -pid)
|
|
199
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
200
|
+
nil
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def process_group_alive?(pid)
|
|
204
|
+
Process.kill(0, -pid)
|
|
205
|
+
true
|
|
206
|
+
rescue Errno::ESRCH
|
|
207
|
+
false
|
|
208
|
+
rescue Errno::EPERM
|
|
209
|
+
true
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def monotonic_time
|
|
213
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def drain(io, max_output_bytes)
|
|
217
|
+
captured = +""
|
|
218
|
+
while (chunk = io.read(16_384))
|
|
219
|
+
remaining = max_output_bytes + 1 - captured.bytesize
|
|
220
|
+
captured << chunk.byteslice(0, remaining) if remaining.positive?
|
|
221
|
+
end
|
|
222
|
+
truncate(captured, max_output_bytes)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def truncate(output, max_output_bytes)
|
|
226
|
+
return output if output.bytesize <= max_output_bytes
|
|
227
|
+
|
|
228
|
+
"#{output.byteslice(0, max_output_bytes)}\n[output truncated]"
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def existing_path(path, allow_root: false)
|
|
232
|
+
candidate = expanded_path(path, allow_root:)
|
|
233
|
+
validate_root!
|
|
234
|
+
resolved = File.realpath(candidate)
|
|
235
|
+
ensure_within_root!(resolved)
|
|
236
|
+
resolved
|
|
237
|
+
rescue Errno::ENOENT
|
|
238
|
+
raise ToolError, "Path does not exist"
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def writable_path(path)
|
|
242
|
+
candidate = expanded_path(path)
|
|
243
|
+
validate_root!
|
|
244
|
+
raise ToolError, "Write target cannot be a symbolic link" if File.symlink?(candidate)
|
|
245
|
+
|
|
246
|
+
if File.exist?(candidate)
|
|
247
|
+
resolved = File.realpath(candidate)
|
|
248
|
+
ensure_within_root!(resolved)
|
|
249
|
+
resolved
|
|
250
|
+
else
|
|
251
|
+
parent = File.realpath(File.dirname(candidate))
|
|
252
|
+
ensure_within_root!(parent)
|
|
253
|
+
File.join(parent, File.basename(candidate))
|
|
254
|
+
end
|
|
255
|
+
rescue Errno::ENOENT
|
|
256
|
+
raise ToolError, "Path does not exist"
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def expanded_path(path, allow_root: false)
|
|
260
|
+
components = path_components(path)
|
|
261
|
+
raise ToolError, "Path must identify a workspace entry" if components.empty? && !allow_root
|
|
262
|
+
|
|
263
|
+
File.join(@root, *components)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def ensure_within_root!(path)
|
|
267
|
+
root_prefix = @root.end_with?(File::SEPARATOR) ? @root : "#{@root}#{File::SEPARATOR}"
|
|
268
|
+
return path if path == @root || path.start_with?(root_prefix)
|
|
269
|
+
|
|
270
|
+
raise ToolError, "Path escapes the configured workspace"
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def validate_root!
|
|
274
|
+
identity = File.stat(File.realpath(@root)).then { |stat| [stat.dev, stat.ino] }
|
|
275
|
+
raise ToolError, "Workspace root changed after initialization" unless identity == @root_identity
|
|
276
|
+
rescue Errno::ENOENT
|
|
277
|
+
raise ToolError, "Workspace root changed after initialization"
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def capture_root_identity
|
|
281
|
+
@root = File.realpath(@root)
|
|
282
|
+
@root_identity = File.stat(@root).then { |stat| [stat.dev, stat.ino] }.freeze
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def read_flags
|
|
286
|
+
flags = File::RDONLY
|
|
287
|
+
flags |= File::NOFOLLOW if defined?(File::NOFOLLOW)
|
|
288
|
+
flags |= File::NONBLOCK if defined?(File::NONBLOCK)
|
|
289
|
+
flags
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def path_components(path)
|
|
293
|
+
value = String(path)
|
|
294
|
+
raise ToolError, "Path contains a null byte" if value.include?("\0")
|
|
295
|
+
raise ToolError, "Path must be relative to the workspace" if value.start_with?(File::SEPARATOR)
|
|
296
|
+
|
|
297
|
+
value.split(File::SEPARATOR).reject { |component| component.empty? || component == "." }.tap do |components|
|
|
298
|
+
raise ToolError, "Path escapes the configured workspace" if components.include?("..")
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def display_path(path)
|
|
303
|
+
path_components(path).join(File::SEPARATOR)
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LittleGhost
|
|
4
|
+
# Usage makes token accounting consistent across model providers. It keeps
|
|
5
|
+
# input, output, cache, and reasoning counts separate so applications can
|
|
6
|
+
# aggregate them without double-counting.
|
|
7
|
+
#
|
|
8
|
+
# Providers report uncached input, visible output, cache reads, cache writes,
|
|
9
|
+
# and reasoning separately. Invalid or negative values normalize to zero.
|
|
10
|
+
class Usage
|
|
11
|
+
FIELDS = %i[input_tokens output_tokens cache_read_tokens cache_write_tokens reasoning_tokens].freeze # :nodoc:
|
|
12
|
+
|
|
13
|
+
attr_reader(*FIELDS)
|
|
14
|
+
|
|
15
|
+
# Normalizes provider token counts; invalid or negative values become zero.
|
|
16
|
+
def initialize(input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0, reasoning_tokens: 0)
|
|
17
|
+
@input_tokens = integer(input_tokens)
|
|
18
|
+
@output_tokens = integer(output_tokens)
|
|
19
|
+
@cache_read_tokens = integer(cache_read_tokens)
|
|
20
|
+
@cache_write_tokens = integer(cache_write_tokens)
|
|
21
|
+
@reasoning_tokens = integer(reasoning_tokens)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Sums every normalized token field.
|
|
25
|
+
def total_tokens
|
|
26
|
+
FIELDS.sum { |field| public_send(field) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Adds two usage values field by field.
|
|
30
|
+
def +(other)
|
|
31
|
+
self.class.new(**FIELDS.to_h { |field| [field, public_send(field) + other.public_send(field)] })
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Exposes every field and +total_tokens+ as a hash.
|
|
35
|
+
def to_h
|
|
36
|
+
FIELDS.to_h { |field| [field, public_send(field)] }.merge(total_tokens: total_tokens)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def integer(value)
|
|
42
|
+
[Integer(value || 0), 0].max
|
|
43
|
+
rescue ArgumentError, TypeError
|
|
44
|
+
0
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LittleGhost
|
|
4
|
+
# Build agentic workflows with ordinary Ruby branching and local variables.
|
|
5
|
+
# A workflow composes several agents, consumes intermediate answers, and
|
|
6
|
+
# streams one final agent response.
|
|
7
|
+
#
|
|
8
|
+
# A support workflow can route a difficult request through research before the
|
|
9
|
+
# responder writes the caller-visible answer:
|
|
10
|
+
#
|
|
11
|
+
# class ResponseWorkflow < LittleGhost::Workflow
|
|
12
|
+
# private
|
|
13
|
+
#
|
|
14
|
+
# def perform
|
|
15
|
+
# route = invoke(RouterAgent).output
|
|
16
|
+
# return invoke(CustomerSupportAgent) unless route["research"]
|
|
17
|
+
#
|
|
18
|
+
# evidence = invoke(ResearchAgent).output
|
|
19
|
+
# invoke CustomerSupportAgent, input: <<~PROMPT
|
|
20
|
+
# #{input.text}
|
|
21
|
+
#
|
|
22
|
+
# Research:
|
|
23
|
+
# #{evidence}
|
|
24
|
+
# PROMPT
|
|
25
|
+
# end
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# run = runtime.build_run(
|
|
29
|
+
# {message: "Why is transfer 481 pending?"},
|
|
30
|
+
# agent_class: CustomerSupportAgent,
|
|
31
|
+
# entrypoint_class: ResponseWorkflow
|
|
32
|
+
# ).call
|
|
33
|
+
# run.response # => "Transfer 481 is waiting for the receiving bank."
|
|
34
|
+
#
|
|
35
|
+
# +invoke+ returns a lazy Workflow::Invocation. Reading +output+ consumes an
|
|
36
|
+
# intermediate invocation and returns RunResult#output; +perform+ must return
|
|
37
|
+
# its final invocation without consuming it so those events reach the caller.
|
|
38
|
+
# Intermediate usage is added to the final result.
|
|
39
|
+
#
|
|
40
|
+
# Every child inherits input, history, settings, cancellation, deadline,
|
|
41
|
+
# template paths, and trace parentage unless +invoke+ overrides its input.
|
|
42
|
+
# JSON-like context is copied for each child, preventing one intermediate agent
|
|
43
|
+
# from mutating a sibling's state. Non-JSON-like workflow context raises
|
|
44
|
+
# ArgumentError.
|
|
45
|
+
#
|
|
46
|
+
# A workflow instance streams once. Returning the wrong value, returning an
|
|
47
|
+
# already consumed invocation, or consuming an invocation twice raises
|
|
48
|
+
# ProtocolError. Built agents close in reverse order, and the first cleanup
|
|
49
|
+
# failure is re-raised after every invocation has been given a chance to close.
|
|
50
|
+
# Composition errors emit an +invocation_error+ event and then re-raise.
|
|
51
|
+
class Workflow
|
|
52
|
+
# Hold one lazy agent call inside a workflow composition.
|
|
53
|
+
# Workflow implementations normally use only its output method or return the
|
|
54
|
+
# object as the final invocation.
|
|
55
|
+
class Invocation
|
|
56
|
+
attr_reader :result # :nodoc:
|
|
57
|
+
|
|
58
|
+
def initialize(input:, history:, context:, build:, on_usage:) # :nodoc:
|
|
59
|
+
@input = input
|
|
60
|
+
@history = history
|
|
61
|
+
@context = context
|
|
62
|
+
@build = build
|
|
63
|
+
@on_usage = on_usage
|
|
64
|
+
@mutex = Mutex.new
|
|
65
|
+
@consumed = false
|
|
66
|
+
@closed = false
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def each(checkpoint: nil) # :nodoc:
|
|
70
|
+
return enum_for(__method__, checkpoint:) unless block_given?
|
|
71
|
+
|
|
72
|
+
agent, options = @mutex.synchronize do
|
|
73
|
+
raise Error, "workflow invocation is already closed" if @closed
|
|
74
|
+
raise ProtocolError, "workflow invocation was already consumed" if @consumed
|
|
75
|
+
|
|
76
|
+
@consumed = true
|
|
77
|
+
@agent, options = @build.call
|
|
78
|
+
[@agent, options]
|
|
79
|
+
end
|
|
80
|
+
begin
|
|
81
|
+
agent.stream(
|
|
82
|
+
@input,
|
|
83
|
+
history: @history,
|
|
84
|
+
context: @context,
|
|
85
|
+
**options,
|
|
86
|
+
checkpoint:
|
|
87
|
+
).each do |event|
|
|
88
|
+
@result = event.data[:result] if event.type == :invocation_stop
|
|
89
|
+
@usage = event.data[:usage] if event.type == :invocation_error
|
|
90
|
+
yield event
|
|
91
|
+
end
|
|
92
|
+
ensure
|
|
93
|
+
report_usage if @intermediate
|
|
94
|
+
close
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Consumes this invocation when necessary and returns RunResult#output.
|
|
99
|
+
#
|
|
100
|
+
# A structured agent returns its validated value; an ordinary agent returns
|
|
101
|
+
# response text. Intermediate usage is recorded for the workflow total.
|
|
102
|
+
def output
|
|
103
|
+
@intermediate = true
|
|
104
|
+
each {} unless consumed?
|
|
105
|
+
result&.output
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def consumed? # :nodoc:
|
|
109
|
+
@mutex.synchronize { @consumed }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def close # :nodoc:
|
|
113
|
+
agent = @mutex.synchronize do
|
|
114
|
+
return if @closed
|
|
115
|
+
|
|
116
|
+
@closed = true
|
|
117
|
+
@agent
|
|
118
|
+
end
|
|
119
|
+
agent&.close
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
def report_usage
|
|
125
|
+
usage = result&.usage || @usage
|
|
126
|
+
@on_usage.call(usage) if usage
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Owning run and the runtime used to resolve workflow agents.
|
|
131
|
+
attr_reader :run, :runtime
|
|
132
|
+
|
|
133
|
+
def initialize(run:, runtime: run.runtime) # :nodoc:
|
|
134
|
+
@run = run
|
|
135
|
+
@runtime = runtime
|
|
136
|
+
@mutex = Mutex.new
|
|
137
|
+
@closed = false
|
|
138
|
+
@started = false
|
|
139
|
+
@invocations = []
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Additional prompt locals shared by agents invoked from the workflow.
|
|
143
|
+
# Subclasses may override this hook.
|
|
144
|
+
def prompt_locals = {}
|
|
145
|
+
|
|
146
|
+
# Streams the workflow once as StreamEvent objects.
|
|
147
|
+
#
|
|
148
|
+
# +perform+ must return a final, unconsumed Workflow::Invocation. The returned
|
|
149
|
+
# Enumerator is lazy, but calling +stream+ reserves the single-use workflow
|
|
150
|
+
# instance even when enumeration has not started yet.
|
|
151
|
+
def stream(
|
|
152
|
+
input = nil,
|
|
153
|
+
history: nil,
|
|
154
|
+
context: nil,
|
|
155
|
+
cancellation_token: Support::CancellationToken.new,
|
|
156
|
+
deadline: nil,
|
|
157
|
+
settings: nil,
|
|
158
|
+
template_locals: nil,
|
|
159
|
+
template_paths: nil,
|
|
160
|
+
parent_operation_id: nil,
|
|
161
|
+
checkpoint: nil
|
|
162
|
+
)
|
|
163
|
+
raise ArgumentError, "input is required" if input.nil?
|
|
164
|
+
|
|
165
|
+
@mutex.synchronize do
|
|
166
|
+
raise Error, "workflow is already closed" if @closed
|
|
167
|
+
raise Error, "workflow instances can only be streamed once" if @started
|
|
168
|
+
|
|
169
|
+
@started = true
|
|
170
|
+
@input = input.is_a?(Message) ? input : Message.new(role: :user, content: input)
|
|
171
|
+
@history = normalize_history(history)
|
|
172
|
+
@context = context || {}
|
|
173
|
+
@cancellation_token = cancellation_token
|
|
174
|
+
@deadline = deadline
|
|
175
|
+
@settings = settings || {}
|
|
176
|
+
@template_locals = template_locals || {}
|
|
177
|
+
@template_paths = template_paths || []
|
|
178
|
+
@parent_operation_id = parent_operation_id
|
|
179
|
+
@checkpoint = checkpoint
|
|
180
|
+
@intermediate_usage = Usage.new
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
Enumerator.new do |events|
|
|
184
|
+
error_emitted = false
|
|
185
|
+
observed_usage = nil
|
|
186
|
+
ensure_open!
|
|
187
|
+
final_invocation = perform
|
|
188
|
+
unless final_invocation.is_a?(Invocation) && !final_invocation.consumed?
|
|
189
|
+
raise ProtocolError, "#{self.class} must return its final invoke from perform"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
final_invocation.each(checkpoint: @checkpoint) do |event|
|
|
193
|
+
error_emitted = true if event.type == :invocation_error
|
|
194
|
+
event = aggregate_usage(event)
|
|
195
|
+
observed_usage = case event.type
|
|
196
|
+
when :invocation_stop
|
|
197
|
+
event.data.fetch(:result).usage
|
|
198
|
+
when :invocation_error
|
|
199
|
+
event.data[:usage] || observed_usage
|
|
200
|
+
else
|
|
201
|
+
observed_usage
|
|
202
|
+
end
|
|
203
|
+
events << event
|
|
204
|
+
end
|
|
205
|
+
rescue => error
|
|
206
|
+
unless error_emitted
|
|
207
|
+
events << StreamEvent.build(
|
|
208
|
+
:invocation_error,
|
|
209
|
+
error:,
|
|
210
|
+
usage: observed_usage || workflow_usage,
|
|
211
|
+
metadata: {}
|
|
212
|
+
)
|
|
213
|
+
end
|
|
214
|
+
raise
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Closes all built agent invocations in reverse order.
|
|
219
|
+
#
|
|
220
|
+
# The operation is idempotent, attempts every close, and raises the first
|
|
221
|
+
# cleanup failure.
|
|
222
|
+
def close
|
|
223
|
+
invocations = @mutex.synchronize do
|
|
224
|
+
return if @closed
|
|
225
|
+
|
|
226
|
+
@closed = true
|
|
227
|
+
@invocations.reverse
|
|
228
|
+
end
|
|
229
|
+
errors = []
|
|
230
|
+
invocations.each do |invocation|
|
|
231
|
+
invocation.close
|
|
232
|
+
rescue => error
|
|
233
|
+
errors << error
|
|
234
|
+
end
|
|
235
|
+
raise errors.first if errors.any?
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
private
|
|
239
|
+
|
|
240
|
+
# The current normalized input, frozen history, and JSON-like context exposed
|
|
241
|
+
# to workflow implementations.
|
|
242
|
+
# :doc:
|
|
243
|
+
attr_reader :input, :history, :context
|
|
244
|
+
|
|
245
|
+
# :doc:
|
|
246
|
+
# Implements the composition and returns its final unconsumed invocation.
|
|
247
|
+
# Subclasses must override this hook.
|
|
248
|
+
def perform
|
|
249
|
+
raise NotImplementedError, "#{self.class} must implement #perform"
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# :doc:
|
|
253
|
+
# Creates a lazy invocation for +agent_class_or_name+.
|
|
254
|
+
#
|
|
255
|
+
# Intermediate calls may use +output+; the final call must be returned from
|
|
256
|
+
# +perform+ without being consumed.
|
|
257
|
+
def invoke(agent_class_or_name, input: self.input, history: self.history, context: self.context)
|
|
258
|
+
invocation = Invocation.new(
|
|
259
|
+
input:,
|
|
260
|
+
history:,
|
|
261
|
+
context: isolated_state(context),
|
|
262
|
+
build: lambda {
|
|
263
|
+
agent = runtime.build_agent(agent_class_or_name, run:)
|
|
264
|
+
begin
|
|
265
|
+
[
|
|
266
|
+
agent,
|
|
267
|
+
{
|
|
268
|
+
cancellation_token: @cancellation_token,
|
|
269
|
+
deadline: @deadline,
|
|
270
|
+
settings: @settings,
|
|
271
|
+
template_locals: template_locals_for(agent),
|
|
272
|
+
template_paths: @template_paths,
|
|
273
|
+
parent_operation_id: @parent_operation_id
|
|
274
|
+
}
|
|
275
|
+
]
|
|
276
|
+
rescue
|
|
277
|
+
agent.close
|
|
278
|
+
raise
|
|
279
|
+
end
|
|
280
|
+
},
|
|
281
|
+
on_usage: ->(usage) { record_intermediate_usage(usage) }
|
|
282
|
+
)
|
|
283
|
+
@mutex.synchronize do
|
|
284
|
+
raise Error, "workflow is already closed" if @closed
|
|
285
|
+
|
|
286
|
+
@invocations << invocation
|
|
287
|
+
end
|
|
288
|
+
invocation
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def record_intermediate_usage(usage)
|
|
292
|
+
@mutex.synchronize { @intermediate_usage += usage }
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def aggregate_usage(event)
|
|
296
|
+
case event.type
|
|
297
|
+
when :invocation_stop
|
|
298
|
+
result = event.data.fetch(:result)
|
|
299
|
+
combined = RunResult.new(
|
|
300
|
+
message: result.message,
|
|
301
|
+
stop_reason: result.stop_reason,
|
|
302
|
+
usage: workflow_usage + result.usage,
|
|
303
|
+
messages: result.messages,
|
|
304
|
+
state: result.state,
|
|
305
|
+
structured_result: result.structured_result
|
|
306
|
+
)
|
|
307
|
+
StreamEvent.build(event.type, **event.data.merge(result: combined))
|
|
308
|
+
when :invocation_error
|
|
309
|
+
usage = event.data[:usage]
|
|
310
|
+
return event unless usage
|
|
311
|
+
|
|
312
|
+
StreamEvent.build(event.type, **event.data.merge(usage: workflow_usage + usage))
|
|
313
|
+
else
|
|
314
|
+
event
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def template_locals_for(agent)
|
|
319
|
+
@template_locals.merge(runtime.template_locals(run:, agent:))
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def isolated_state(value)
|
|
323
|
+
case value
|
|
324
|
+
when Hash
|
|
325
|
+
value.to_h { |key, item| [isolated_state(key), isolated_state(item)] }
|
|
326
|
+
when Array
|
|
327
|
+
value.map { |item| isolated_state(item) }
|
|
328
|
+
when String
|
|
329
|
+
value.dup
|
|
330
|
+
when NilClass, TrueClass, FalseClass, Numeric, Symbol
|
|
331
|
+
value
|
|
332
|
+
else
|
|
333
|
+
raise ArgumentError, "workflow context must contain only JSON-like state"
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def workflow_usage
|
|
338
|
+
@mutex.synchronize { @intermediate_usage }
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def ensure_open!
|
|
342
|
+
@mutex.synchronize { raise Error, "workflow is already closed" if @closed }
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def normalize_history(value)
|
|
346
|
+
return [].freeze if value.nil?
|
|
347
|
+
|
|
348
|
+
Array(value).map { |message| Message.coerce(message) }.freeze
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
end
|