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.
Files changed (82) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +22 -0
  3. data/README.md +122 -0
  4. data/docs/guides/Core Concepts.md +203 -0
  5. data/docs/guides/Getting Started.md +187 -0
  6. data/lib/little_ghost/ag_ui/adapter.rb +194 -0
  7. data/lib/little_ghost/ag_ui.rb +5 -0
  8. data/lib/little_ghost/agent/context_management.rb +285 -0
  9. data/lib/little_ghost/agent/delegation.rb +128 -0
  10. data/lib/little_ghost/agent/skills.rb +96 -0
  11. data/lib/little_ghost/agent/tool_loop.rb +239 -0
  12. data/lib/little_ghost/agent.rb +2111 -0
  13. data/lib/little_ghost/agent_builder.rb +191 -0
  14. data/lib/little_ghost/agent_interruptions.rb +197 -0
  15. data/lib/little_ghost/configuration.rb +337 -0
  16. data/lib/little_ghost/content.rb +324 -0
  17. data/lib/little_ghost/default_model_registry.rb +71 -0
  18. data/lib/little_ghost/errors.rb +48 -0
  19. data/lib/little_ghost/events.rb +264 -0
  20. data/lib/little_ghost/execution_state.rb +58 -0
  21. data/lib/little_ghost/instrumentation.rb +475 -0
  22. data/lib/little_ghost/invocation.rb +285 -0
  23. data/lib/little_ghost/lookup.rb +37 -0
  24. data/lib/little_ghost/mcp/client.rb +396 -0
  25. data/lib/little_ghost/mcp.rb +5 -0
  26. data/lib/little_ghost/message.rb +75 -0
  27. data/lib/little_ghost/model.rb +88 -0
  28. data/lib/little_ghost/model_capabilities.rb +126 -0
  29. data/lib/little_ghost/model_registry.rb +173 -0
  30. data/lib/little_ghost/model_request.rb +107 -0
  31. data/lib/little_ghost/model_response.rb +48 -0
  32. data/lib/little_ghost/path_set.rb +32 -0
  33. data/lib/little_ghost/prompt_resolver.rb +251 -0
  34. data/lib/little_ghost/providers/bedrock.rb +506 -0
  35. data/lib/little_ghost/providers/http_transport.rb +149 -0
  36. data/lib/little_ghost/providers/open_router.rb +171 -0
  37. data/lib/little_ghost/providers/openai.rb +27 -0
  38. data/lib/little_ghost/providers/openai_compatible.rb +745 -0
  39. data/lib/little_ghost/providers/sse_parser.rb +35 -0
  40. data/lib/little_ghost/run.rb +607 -0
  41. data/lib/little_ghost/run_context.rb +129 -0
  42. data/lib/little_ghost/run_result.rb +111 -0
  43. data/lib/little_ghost/runtime/hook.rb +31 -0
  44. data/lib/little_ghost/runtime.rb +392 -0
  45. data/lib/little_ghost/sandbox.rb +138 -0
  46. data/lib/little_ghost/session.rb +229 -0
  47. data/lib/little_ghost/session_store.rb +96 -0
  48. data/lib/little_ghost/session_stores/agent_core_memory.rb +1086 -0
  49. data/lib/little_ghost/session_stores/memory.rb +86 -0
  50. data/lib/little_ghost/skills/catalog.rb +283 -0
  51. data/lib/little_ghost/skills/skill.rb +60 -0
  52. data/lib/little_ghost/skills.rb +4 -0
  53. data/lib/little_ghost/stream_event.rb +49 -0
  54. data/lib/little_ghost/structured_output.rb +126 -0
  55. data/lib/little_ghost/subagents/agent_path.rb +63 -0
  56. data/lib/little_ghost/subagents/definition.rb +42 -0
  57. data/lib/little_ghost/subagents/manager.rb +1615 -0
  58. data/lib/little_ghost/support/callbacks.rb +151 -0
  59. data/lib/little_ghost/support/cancellation_token.rb +86 -0
  60. data/lib/little_ghost/support/class_attributes.rb +40 -0
  61. data/lib/little_ghost/support/content_capture.rb +150 -0
  62. data/lib/little_ghost/support/executor.rb +75 -0
  63. data/lib/little_ghost/support/interruptible_stream.rb +103 -0
  64. data/lib/little_ghost/support/loader.rb +263 -0
  65. data/lib/little_ghost/support/output_truncation.rb +71 -0
  66. data/lib/little_ghost/support/redactor.rb +66 -0
  67. data/lib/little_ghost/support.rb +34 -0
  68. data/lib/little_ghost/tool.rb +448 -0
  69. data/lib/little_ghost/tool_execution.rb +59 -0
  70. data/lib/little_ghost/tool_registry.rb +156 -0
  71. data/lib/little_ghost/tools/filesystem.rb +119 -0
  72. data/lib/little_ghost/tools/shell.rb +45 -0
  73. data/lib/little_ghost/tools/write_todos.rb +91 -0
  74. data/lib/little_ghost/tools.rb +6 -0
  75. data/lib/little_ghost/tracing/open_telemetry.rb +517 -0
  76. data/lib/little_ghost/unrestricted_sandbox.rb +306 -0
  77. data/lib/little_ghost/usage.rb +47 -0
  78. data/lib/little_ghost/version.rb +6 -0
  79. data/lib/little_ghost/workflow.rb +351 -0
  80. data/lib/little_ghost/workspace.rb +31 -0
  81. data/lib/little_ghost.rb +120 -0
  82. 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,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LittleGhost
4
+ # Current LittleGhost gem version.
5
+ VERSION = "0.1.0"
6
+ 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