little_ghost 0.2.0 → 0.3.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.
@@ -1,9 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "assembly"
4
+
3
5
  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.
6
+ # Coordinates Assembly participants with ordinary Ruby control flow.
7
+ #
8
+ # A workflow is an Assembly whose +perform+ method controls ordering,
9
+ # branching, parallel work, and local variables. Each participant may be an
10
+ # Agent or another coordinated Assembly. The workflow consumes intermediate
11
+ # answers and streams one final participant response.
7
12
  #
8
13
  # A support workflow can route a difficult request through research before the
9
14
  # responder writes the caller-visible answer:
@@ -25,11 +30,7 @@ module LittleGhost
25
30
  # end
26
31
  # end
27
32
  #
28
- # run = runtime.build_run(
29
- # {message: "Why is transfer 481 pending?"},
30
- # agent_class: CustomerSupportAgent,
31
- # entrypoint_class: ResponseWorkflow
32
- # ).call
33
+ # run = ResponseWorkflow.ask("Why is transfer 481 pending?")
33
34
  # run.response # => "Transfer 481 is waiting for the receiving bank."
34
35
  #
35
36
  # +invoke+ returns a lazy Workflow::Invocation. Reading +output+ consumes an
@@ -45,22 +46,25 @@ module LittleGhost
45
46
  #
46
47
  # A workflow instance streams once. Returning the wrong value, returning an
47
48
  # 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
49
+ # ProtocolError. Each child assembly closes after its execution attempt;
50
+ # cleanup failures surface from that attempt. Closing the Workflow closes its
51
+ # lightweight invocation wrappers in reverse declaration order. Composition
52
+ # errors emit an +invocation_error+ event and then re-raise.
53
+ class Workflow < Assembly
52
54
  # Hold one lazy agent call inside a workflow composition.
53
55
  # Workflow implementations normally use only its output method or return the
54
56
  # object as the final invocation.
55
57
  class Invocation
56
58
  attr_reader :result # :nodoc:
57
59
 
58
- def initialize(input:, history:, context:, build:, on_usage:) # :nodoc:
60
+ def initialize(reference:, participant:, input:, history:, context:, policies:, owner:) # :nodoc:
61
+ @reference = reference
62
+ @participant = participant
59
63
  @input = input
60
64
  @history = history
61
65
  @context = context
62
- @build = build
63
- @on_usage = on_usage
66
+ @policies = policies
67
+ @owner = owner
64
68
  @mutex = Mutex.new
65
69
  @consumed = false
66
70
  @closed = false
@@ -69,30 +73,29 @@ module LittleGhost
69
73
  def each(checkpoint: nil) # :nodoc:
70
74
  return enum_for(__method__, checkpoint:) unless block_given?
71
75
 
72
- agent, options = @mutex.synchronize do
76
+ @mutex.synchronize do
73
77
  raise Error, "workflow invocation is already closed" if @closed
74
78
  raise ProtocolError, "workflow invocation was already consumed" if @consumed
75
79
 
76
80
  @consumed = true
77
- @agent, options = @build.call
78
- [@agent, options]
79
81
  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
82
+ execution = @owner.send(
83
+ :execute_workflow_invocation,
84
+ reference: @reference,
85
+ participant: @participant,
86
+ input: @input,
87
+ history: @history,
88
+ context: @context,
89
+ policies: @policies,
90
+ checkpoint:
91
+ ) { |event| yield event }
92
+ @result = execution.result
93
+ @step = execution.step
94
+ @steps = execution.result.steps
95
+ execution.events.each do |event|
96
+ yield event
95
97
  end
98
+ @result
96
99
  end
97
100
 
98
101
  # Consumes this invocation when necessary and returns RunResult#output.
@@ -101,7 +104,12 @@ module LittleGhost
101
104
  # response text. Intermediate usage is recorded for the workflow total.
102
105
  def output
103
106
  @intermediate = true
104
- each {} unless consumed?
107
+ unless consumed?
108
+ each do |event|
109
+ @owner.send(:emit_workflow_event, event) if event.type.to_s.start_with?("assembly_")
110
+ end
111
+ @owner.send(:record_workflow_steps, @steps)
112
+ end
105
113
  result&.output
106
114
  end
107
115
 
@@ -110,29 +118,19 @@ module LittleGhost
110
118
  end
111
119
 
112
120
  def close # :nodoc:
113
- agent = @mutex.synchronize do
121
+ @mutex.synchronize do
114
122
  return if @closed
115
123
 
116
124
  @closed = true
117
- @agent
118
125
  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
126
  end
128
127
  end
129
128
 
130
129
  # Owning run and the runtime used to resolve workflow agents.
131
130
  attr_reader :run, :runtime
132
131
 
133
- def initialize(run:, runtime: run.runtime) # :nodoc:
134
- @run = run
135
- @runtime = runtime
132
+ def initialize(run: nil, runtime: nil) # :nodoc:
133
+ super(run:, runtime:, standalone: run.nil?)
136
134
  @mutex = Mutex.new
137
135
  @closed = false
138
136
  @started = false
@@ -162,6 +160,17 @@ module LittleGhost
162
160
  )
163
161
  raise ArgumentError, "input is required" if input.nil?
164
162
 
163
+ if standalone?
164
+ return build_run(entrypoint_payload(input, {
165
+ history:,
166
+ context:,
167
+ settings:,
168
+ template_paths:,
169
+ deadline_at: deadline,
170
+ cancellation_token:
171
+ }.compact)).each
172
+ end
173
+
165
174
  @mutex.synchronize do
166
175
  raise Error, "workflow is already closed" if @closed
167
176
  raise Error, "workflow instances can only be streamed once" if @started
@@ -178,11 +187,14 @@ module LittleGhost
178
187
  @parent_operation_id = parent_operation_id
179
188
  @checkpoint = checkpoint
180
189
  @intermediate_usage = Usage.new
190
+ @workflow_steps = []
191
+ @workflow_events = nil
181
192
  end
182
193
 
183
194
  Enumerator.new do |events|
184
195
  error_emitted = false
185
196
  observed_usage = nil
197
+ @workflow_events = events
186
198
  ensure_open!
187
199
  final_invocation = perform
188
200
  unless final_invocation.is_a?(Invocation) && !final_invocation.consumed?
@@ -197,6 +209,8 @@ module LittleGhost
197
209
  event.data.fetch(:result).usage
198
210
  when :invocation_error
199
211
  event.data[:usage] || observed_usage
212
+ when :assembly_step_error
213
+ event.data[:usage] || observed_usage
200
214
  else
201
215
  observed_usage
202
216
  end
@@ -212,10 +226,12 @@ module LittleGhost
212
226
  )
213
227
  end
214
228
  raise
229
+ ensure
230
+ @workflow_events = nil
215
231
  end
216
232
  end
217
233
 
218
- # Closes all built agent invocations in reverse order.
234
+ # Closes all declared invocations in reverse order.
219
235
  #
220
236
  # The operation is idempotent, attempts every close, and raises the first
221
237
  # cleanup failure.
@@ -250,35 +266,30 @@ module LittleGhost
250
266
  end
251
267
 
252
268
  # :doc:
253
- # Creates a lazy invocation for +agent_class_or_name+.
269
+ # Creates a lazy invocation for +assembly+.
254
270
  #
255
271
  # Intermediate calls may use +output+; the final call must be returned from
256
272
  # +perform+ without being consumed.
257
- def invoke(agent_class_or_name, input: self.input, history: self.history, context: self.context)
273
+ def invoke(
274
+ assembly,
275
+ as: nil,
276
+ input: self.input,
277
+ history: self.history,
278
+ context: self.context,
279
+ timeout: nil,
280
+ retries: 0,
281
+ retry_on: nil,
282
+ retry_delay: 0
283
+ )
284
+ participant = as || assembly_identity(assembly)
258
285
  invocation = Invocation.new(
286
+ reference: assembly,
287
+ participant:,
259
288
  input:,
260
289
  history:,
261
290
  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) }
291
+ policies: {timeout:, retries:, retry_on:, retry_delay:},
292
+ owner: self
282
293
  )
283
294
  @mutex.synchronize do
284
295
  raise Error, "workflow is already closed" if @closed
@@ -288,8 +299,112 @@ module LittleGhost
288
299
  invocation
289
300
  end
290
301
 
291
- def record_intermediate_usage(usage)
292
- @mutex.synchronize { @intermediate_usage += usage }
302
+ # Consumes independent invocations concurrently and returns their outputs in
303
+ # declaration order.
304
+ def parallel(*invocations, max_concurrency: 8)
305
+ raise ArgumentError, "parallel requires at least one invocation" if invocations.empty?
306
+ unless invocations.all? { |invocation| invocation.is_a?(Invocation) && !invocation.consumed? }
307
+ raise ArgumentError, "parallel accepts unconsumed workflow invocations"
308
+ end
309
+
310
+ token = @cancellation_token.child
311
+ queue = SizedQueue.new(1_000)
312
+ worker = Thread.new do
313
+ results = Support::Executor.new(max_concurrency:).map(
314
+ invocations,
315
+ cancellation_token: token,
316
+ on_result: ->(_index, execution) { record_workflow_steps(execution.fetch(:steps)) }
317
+ ) do |invocation|
318
+ invocation.each do |event|
319
+ if event.type.to_s.start_with?("assembly_")
320
+ enqueue_assembly_event(queue, [:event, event], token)
321
+ end
322
+ end
323
+ {output: invocation.result&.output, steps: invocation.instance_variable_get(:@steps)}
324
+ end
325
+ enqueue_assembly_event(queue, [:done, results], token)
326
+ rescue => error
327
+ token.cancel
328
+ enqueue_assembly_terminal(queue, [:error, error])
329
+ end
330
+ executions = loop do
331
+ type, value = queue.pop
332
+ emit_workflow_event(value) if type == :event
333
+ raise value if type == :error
334
+ break value if type == :done
335
+ end
336
+ executions.map { |execution| execution.fetch(:output) }
337
+ ensure
338
+ token&.cancel
339
+ worker&.join
340
+ end
341
+
342
+ def execute_workflow_invocation(reference:, participant:, input:, history:, context:, policies:, checkpoint:)
343
+ step_id = SecureRandom.uuid
344
+ predecessor_id = @mutex.synchronize do
345
+ @workflow_steps.reverse.find { |step| step.parent_id.nil? }&.id
346
+ end
347
+ yield StreamEvent.build(
348
+ :assembly_step_start,
349
+ assembly_id: self.class.assembly_id,
350
+ assembly_kind: :workflow,
351
+ participant: participant.to_s,
352
+ step_id:
353
+ )
354
+ execution = execute_assembly_step(
355
+ reference:,
356
+ participant:,
357
+ input:,
358
+ history:,
359
+ context:,
360
+ cancellation_token: @cancellation_token,
361
+ deadline: @deadline,
362
+ settings: @settings,
363
+ template_locals: @template_locals,
364
+ template_paths: @template_paths,
365
+ parent_operation_id: @parent_operation_id,
366
+ policies:,
367
+ predecessor_ids: Array(predecessor_id),
368
+ checkpoint:,
369
+ step_id:
370
+ ) { |event| yield event }
371
+ yield StreamEvent.build(
372
+ :assembly_step_stop,
373
+ assembly_id: self.class.assembly_id,
374
+ assembly_kind: :workflow,
375
+ participant: participant.to_s,
376
+ step_id: execution.step.id,
377
+ usage: execution.step.usage
378
+ )
379
+ execution
380
+ end
381
+
382
+ def record_workflow_steps(steps)
383
+ @mutex.synchronize do
384
+ @workflow_steps.concat(steps)
385
+ @intermediate_usage += steps.first.usage
386
+ end
387
+ end
388
+
389
+ def emit_workflow_event(event)
390
+ sink = @mutex.synchronize do
391
+ if event.type == :assembly_step_error && event.data[:terminal] && event.data[:usage]
392
+ @intermediate_usage += event.data.fetch(:usage)
393
+ end
394
+ @workflow_events
395
+ end
396
+ sink << event if sink
397
+ end
398
+
399
+ def assembly_identity(reference)
400
+ case reference
401
+ when AssemblyBuilder, AssemblyDefinition
402
+ reference.assembly_id
403
+ when Class
404
+ (reference <= Assembly) ? reference.assembly_id : reference.to_s
405
+ else
406
+ reference.to_s
407
+ end
293
408
  end
294
409
 
295
410
  def aggregate_usage(event)
@@ -302,13 +417,19 @@ module LittleGhost
302
417
  usage: workflow_usage + result.usage,
303
418
  messages: result.messages,
304
419
  state: result.state,
305
- structured_result: result.structured_result
420
+ structured_result: result.structured_result,
421
+ steps: workflow_steps + result.steps
306
422
  )
307
423
  StreamEvent.build(event.type, **event.data.merge(result: combined))
308
424
  when :invocation_error
309
425
  usage = event.data[:usage]
310
426
  return event unless usage
311
427
 
428
+ StreamEvent.build(event.type, **event.data.merge(usage: workflow_usage + usage))
429
+ when :assembly_step_error
430
+ usage = event.data[:usage]
431
+ return event unless usage
432
+
312
433
  StreamEvent.build(event.type, **event.data.merge(usage: workflow_usage + usage))
313
434
  else
314
435
  event
@@ -338,6 +459,10 @@ module LittleGhost
338
459
  @mutex.synchronize { @intermediate_usage }
339
460
  end
340
461
 
462
+ def workflow_steps
463
+ @mutex.synchronize { @workflow_steps.dup.freeze }
464
+ end
465
+
341
466
  def ensure_open!
342
467
  @mutex.synchronize { raise Error, "workflow is already closed" if @closed }
343
468
  end
data/lib/little_ghost.rb CHANGED
@@ -54,17 +54,23 @@ require_relative "little_ghost/session"
54
54
  require_relative "little_ghost/skills"
55
55
  require_relative "little_ghost/tools/write_todos"
56
56
  require_relative "little_ghost/run"
57
+ require_relative "little_ghost/execution"
57
58
  require_relative "little_ghost/subagents/definition"
58
59
  require_relative "little_ghost/subagents/agent_path"
59
60
  require_relative "little_ghost/subagents/manager"
60
61
  require_relative "little_ghost/agent_interruptions"
62
+ require_relative "little_ghost/assembly"
63
+ require_relative "little_ghost/assembly_execution"
61
64
  require_relative "little_ghost/agent"
62
- require_relative "little_ghost/agent_builder"
63
65
  require_relative "little_ghost/workflow"
66
+ require_relative "little_ghost/swarm"
67
+ require_relative "little_ghost/graph"
68
+ require_relative "little_ghost/assembly_builder"
69
+ require_relative "little_ghost/agent_factory"
64
70
  require_relative "little_ghost/runtime/hook"
65
71
  require_relative "little_ghost/runtime"
66
72
 
67
- # Build AI features with reusable agents and agentic workflows. LittleGhost can
73
+ # Build AI features with reusable agents and composable assemblies. LittleGhost can
68
74
  # sit inside an existing Ruby system or support a dedicated AI service, keeping
69
75
  # models, prompts, tools, sessions, streaming, and instrumentation behind a
70
76
  # cohesive set of Ruby conventions.
@@ -89,8 +95,9 @@ require_relative "little_ghost/runtime"
89
95
  # run.response # => "Transfer 481 is waiting for the receiving bank."
90
96
  #
91
97
  # Agent subclasses hold reusable behavior; Invocation objects carry one request,
92
- # and Run objects own execution and cleanup. Workflow subclasses can compose
93
- # several agents when ordinary Ruby branching is clearer than one agent loop.
98
+ # and Run objects own execution and cleanup. Assembly gives Agent, Workflow,
99
+ # Swarm, and Graph the same caller interface while each type owns a different
100
+ # coordination policy.
94
101
  #
95
102
  # LittleGhost.configuration is process-wide unless LittleGhost.with_configuration
96
103
  # supplies an execution-scoped replacement. Configuration is loaded lazily and
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: little_ghost
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matt Robinson
@@ -107,8 +107,8 @@ dependencies:
107
107
  - - "~>"
108
108
  - !ruby/object:Gem::Version
109
109
  version: '1.9'
110
- description: Add agents, tools, and agentic workflows to existing Ruby systems or
111
- dedicated AI services.
110
+ description: Add agents, tools, workflows, swarms, and graphs to existing Ruby systems
111
+ or dedicated AI services.
112
112
  email:
113
113
  - robinson.matty@gmail.com
114
114
  executables: []
@@ -128,13 +128,19 @@ files:
128
128
  - lib/little_ghost/agent/skills.rb
129
129
  - lib/little_ghost/agent/tool_loop.rb
130
130
  - lib/little_ghost/agent_builder.rb
131
+ - lib/little_ghost/agent_factory.rb
131
132
  - lib/little_ghost/agent_interruptions.rb
133
+ - lib/little_ghost/assembly.rb
134
+ - lib/little_ghost/assembly_builder.rb
135
+ - lib/little_ghost/assembly_execution.rb
132
136
  - lib/little_ghost/configuration.rb
133
137
  - lib/little_ghost/content.rb
134
138
  - lib/little_ghost/data/model_catalog.json
135
139
  - lib/little_ghost/errors.rb
136
140
  - lib/little_ghost/events.rb
141
+ - lib/little_ghost/execution.rb
137
142
  - lib/little_ghost/execution_state.rb
143
+ - lib/little_ghost/graph.rb
138
144
  - lib/little_ghost/instrumentation.rb
139
145
  - lib/little_ghost/invocation.rb
140
146
  - lib/little_ghost/lookup.rb
@@ -203,6 +209,7 @@ files:
203
209
  - lib/little_ghost/support/output_truncation.rb
204
210
  - lib/little_ghost/support/redactor.rb
205
211
  - lib/little_ghost/support/sse_parser.rb
212
+ - lib/little_ghost/swarm.rb
206
213
  - lib/little_ghost/tool.rb
207
214
  - lib/little_ghost/tool_execution.rb
208
215
  - lib/little_ghost/tool_registry.rb
@@ -242,5 +249,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
242
249
  requirements: []
243
250
  rubygems_version: 4.0.10
244
251
  specification_version: 4
245
- summary: A Ruby framework for AI features with agents and agentic workflows
252
+ summary: A Ruby framework for AI features with agents and composable assemblies
246
253
  test_files: []