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.
@@ -0,0 +1,431 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "assembly"
5
+
6
+ module LittleGhost
7
+ # Lets configured Agent members hand one request directly to one another.
8
+ #
9
+ # A swarm is an Assembly for model-selected routing. One member is active at a
10
+ # time. It either produces the final answer or calls a reserved handoff tool
11
+ # to select an allowed next member.
12
+ #
13
+ # class ProblemSolverSwarm < LittleGhost::Swarm
14
+ # member TriageAgent
15
+ # member BillingAgent
16
+ # member AccountAgent
17
+ #
18
+ # start TriageAgent
19
+ # handoff TriageAgent, to: [BillingAgent, AccountAgent]
20
+ # max_steps 12
21
+ # end
22
+ #
23
+ # run = ProblemSolverSwarm.ask("Why was I charged twice?")
24
+ # run.response
25
+ #
26
+ # Swarm members are Agent definitions rather than arbitrary assemblies so a
27
+ # handoff remains a direct model-to-model transition. Original conversation
28
+ # history and application context stay isolated unless a member opts in with
29
+ # +history: true+ or +context: true+. Streams expose coordination lifecycle
30
+ # events and the final member response, but not intermediate model text.
31
+ class Swarm < Assembly
32
+ MAX_BUFFERED_EVENTS = 10_000 # :nodoc:
33
+ MAX_BUFFERED_EVENT_BYTES = 10 * 1024 * 1024 # :nodoc:
34
+ Member = Data.define(:id, :agent, :policies, :inherit_history, :inherit_context) # :nodoc:
35
+ Handoff = Data.define(:from, :to) # :nodoc:
36
+
37
+ extend Support::ClassAttributes
38
+
39
+ class_attribute :swarm_members_value, default: {}.freeze
40
+ class_attribute :swarm_handoffs_value, default: [].freeze
41
+ class_attribute :swarm_start_value
42
+ class_attribute :swarm_max_steps_value, default: 20
43
+ class_attribute :swarm_max_handoff_repeats_value, default: 3
44
+
45
+ class << self
46
+ # Declares one Agent member and its optional execution policy.
47
+ def member(agent, as: nil, timeout: nil, retries: 0, retry_on: nil, retry_delay: 0,
48
+ history: false, context: false)
49
+ validate_agent_reference!(agent)
50
+ id = normalize_member_id(as || agent_reference_id(agent))
51
+ raise ConfigurationError, "swarm member #{id.inspect} is already declared" if swarm_members_value.key?(id)
52
+ unless [history, context].all? { |value| value == true || value == false }
53
+ raise ArgumentError, "swarm member history and context options must be true or false"
54
+ end
55
+
56
+ policies = {timeout:, retries:, retry_on:, retry_delay:}.freeze
57
+ declaration = Member.new(
58
+ id:, agent:, policies:,
59
+ inherit_history: history,
60
+ inherit_context: context
61
+ )
62
+ self.swarm_members_value = swarm_members_value.merge(id => declaration).freeze
63
+ end
64
+
65
+ # Reads or assigns the initial Agent member.
66
+ def start(member = nil)
67
+ return swarm_start_value if member.nil?
68
+
69
+ self.swarm_start_value = normalize_member_id(member_reference_id(member))
70
+ end
71
+
72
+ # Restricts one member to the declared handoff targets.
73
+ def handoff(from, to:)
74
+ from = normalize_member_id(member_reference_id(from))
75
+ targets = Array(to).map { |target| normalize_member_id(member_reference_id(target)) }
76
+ raise ArgumentError, "handoff requires at least one target" if targets.empty?
77
+ raise ArgumentError, "handoff targets must be unique" unless targets.uniq.length == targets.length
78
+
79
+ declaration = Handoff.new(from:, to: targets.freeze)
80
+ self.swarm_handoffs_value = (swarm_handoffs_value + [declaration]).freeze
81
+ declaration
82
+ end
83
+
84
+ # Reads or assigns the maximum member executions.
85
+ def max_steps(value = nil)
86
+ return swarm_max_steps_value if value.nil?
87
+
88
+ value = Integer(value)
89
+ raise ArgumentError, "max_steps must be at least 1" if value < 1
90
+
91
+ self.swarm_max_steps_value = value
92
+ end
93
+
94
+ # Reads or assigns the repeated directed-handoff limit.
95
+ def max_handoff_repeats(value = nil)
96
+ return swarm_max_handoff_repeats_value if value.nil?
97
+
98
+ value = Integer(value)
99
+ raise ArgumentError, "max_handoff_repeats must be at least 1" if value < 1
100
+
101
+ self.swarm_max_handoff_repeats_value = value
102
+ end
103
+
104
+ # Validates the member set and topology and returns this Swarm class.
105
+ def validate!
106
+ swarm_definition!
107
+ self
108
+ end
109
+
110
+ def swarm_definition! # :nodoc:
111
+ members = swarm_members_value
112
+ start_id = swarm_start_value
113
+ raise ConfigurationError, "swarm must declare at least two members" if members.length < 2
114
+ raise ConfigurationError, "swarm must declare a start member" unless start_id
115
+ raise ConfigurationError, "swarm start member #{start_id.inspect} is not declared" unless members.key?(start_id)
116
+
117
+ seen_sources = {}
118
+ members.each_value { |member| validate_step_policy!(member.policies) }
119
+ swarm_handoffs_value.each do |declaration|
120
+ raise ConfigurationError, "swarm handoff source #{declaration.from.inspect} is not declared" unless members.key?(declaration.from)
121
+ if seen_sources[declaration.from]
122
+ raise ConfigurationError, "swarm member #{declaration.from.inspect} has more than one handoff declaration"
123
+ end
124
+ seen_sources[declaration.from] = true
125
+ declaration.to.each do |target|
126
+ raise ConfigurationError, "swarm handoff target #{target.inspect} is not declared" unless members.key?(target)
127
+ raise ConfigurationError, "swarm member #{target.inspect} cannot hand off to itself" if target == declaration.from
128
+ end
129
+ end
130
+ [members, start_id, swarm_handoffs_value]
131
+ end
132
+
133
+ private
134
+
135
+ def validate_agent_reference!(value)
136
+ valid = (value.is_a?(Class) && value <= Agent) ||
137
+ value.is_a?(AgentBuilder) ||
138
+ (value.is_a?(AssemblyDefinition) && value.kind == :agent)
139
+ raise ConfigurationError, "swarm members must be Agent definitions" unless valid
140
+ end
141
+
142
+ def agent_reference_id(value)
143
+ return value.agent_id if value.is_a?(Class)
144
+
145
+ value.assembly_id
146
+ end
147
+
148
+ def member_reference_id(value)
149
+ return agent_reference_id(value) if value.is_a?(Class) || value.is_a?(AgentBuilder) || value.is_a?(AssemblyDefinition)
150
+
151
+ value
152
+ end
153
+
154
+ def normalize_member_id(value)
155
+ value = value.to_s
156
+ raise ArgumentError, "swarm member id cannot be empty" if value.empty?
157
+
158
+ value.freeze
159
+ end
160
+ end
161
+
162
+ def initialize(run: nil, runtime: nil) # :nodoc:
163
+ super(run:, runtime:, standalone: run.nil?)
164
+ @swarm_mutex = Mutex.new
165
+ @swarm_started = false
166
+ end
167
+
168
+ # Streams lifecycle events and only the final member's answer events.
169
+ def stream(input = nil, history: nil, context: nil,
170
+ cancellation_token: Support::CancellationToken.new, deadline: nil,
171
+ settings: nil, template_locals: nil, template_paths: nil,
172
+ parent_operation_id: nil, checkpoint: nil, **_options)
173
+ raise ArgumentError, "input is required" if input.nil?
174
+ if standalone?
175
+ return build_run(entrypoint_payload(input, {
176
+ history:, context:, settings:, template_paths:,
177
+ deadline_at: deadline, cancellation_token:
178
+ }.compact)).each
179
+ end
180
+
181
+ reserve_execution!
182
+ current_input = input.is_a?(Message) ? input : Message.new(role: :user, content: input)
183
+ original_history = normalize_history(history)
184
+ original_context = isolated_assembly_state(context || {})
185
+ settings ||= {}
186
+ template_locals ||= {}
187
+ template_paths ||= []
188
+
189
+ usage = Usage.new
190
+ error_emitted = false
191
+ Enumerator.new do |events|
192
+ members, current, topology = self.class.swarm_definition!
193
+ steps = []
194
+ transitions = Hash.new(0)
195
+ step_number = 0
196
+ previous_step_id = nil
197
+
198
+ loop do
199
+ check_swarm_control!(cancellation_token, deadline, step_number)
200
+ step_number += 1
201
+ member = members.fetch(current)
202
+ allowed = allowed_targets(current, members, topology)
203
+ handoff_tool = handoff_tool_for(current, allowed, members) if allowed.any?
204
+ step_id = SecureRandom.uuid
205
+ events << StreamEvent.build(
206
+ :assembly_step_start,
207
+ assembly_id: self.class.assembly_id,
208
+ assembly_kind: :swarm,
209
+ step: step_number,
210
+ participant: current,
211
+ step_id:
212
+ )
213
+ execution = execute_assembly_step(
214
+ reference: member.agent,
215
+ participant: current,
216
+ input: current_input,
217
+ history: member.inherit_history ? original_history : [],
218
+ context: member.inherit_context ? original_context : {},
219
+ cancellation_token:, deadline:, settings:, template_locals:,
220
+ template_paths:, parent_operation_id:,
221
+ policies: member.policies,
222
+ predecessor_ids: Array(previous_step_id),
223
+ checkpoint: nil,
224
+ build_options: {tools: [handoff_tool].compact},
225
+ step_id:
226
+ ) { |event| events << event }
227
+ result = execution.result
228
+ previous_step_id = execution.step.id
229
+ transition = active_transition(execution, allowed)
230
+ events << StreamEvent.build(
231
+ :assembly_step_stop,
232
+ assembly_id: self.class.assembly_id,
233
+ assembly_kind: :swarm,
234
+ step: step_number,
235
+ participant: current,
236
+ step_id: execution.step.id,
237
+ usage: execution.step.usage
238
+ )
239
+
240
+ if transition
241
+ usage += execution.step.usage
242
+ target = transition.fetch(:agent_id)
243
+ key = [current, target]
244
+ transitions[key] += 1
245
+ if transitions[key] > self.class.max_handoff_repeats
246
+ events << StreamEvent.build(
247
+ :assembly_handoff_loop,
248
+ assembly_id: self.class.assembly_id,
249
+ assembly_kind: :swarm,
250
+ from: current,
251
+ to: target,
252
+ count: transitions[key]
253
+ )
254
+ raise AssemblyLimitError, "swarm repeated handoff #{current.inspect} -> #{target.inspect} too many times"
255
+ end
256
+ sanitized = sanitized_handoff_step(execution.step, transition)
257
+ steps << sanitized
258
+ steps.concat(result.steps.drop(1))
259
+ events << StreamEvent.build(
260
+ :assembly_transition,
261
+ assembly_id: self.class.assembly_id,
262
+ assembly_kind: :swarm,
263
+ step: step_number,
264
+ from: current,
265
+ to: target
266
+ )
267
+ current_input = handoff_input(from: current, transition:)
268
+ current = target
269
+ next
270
+ end
271
+
272
+ usage += execution.step.usage
273
+ steps.concat(result.steps)
274
+ final = copy_run_result(result, usage:, steps: steps.freeze)
275
+ release_final_events(execution.events, final).each { |event| events << event }
276
+ break
277
+ end
278
+ rescue => error
279
+ usage += step_error_usage(error)
280
+ unless error_emitted
281
+ events << StreamEvent.build(:invocation_error, error:, usage:, metadata: {})
282
+ end
283
+ raise
284
+ end
285
+ end
286
+
287
+ private
288
+
289
+ def reserve_execution!
290
+ @swarm_mutex.synchronize do
291
+ raise Error, "swarm instances can only be streamed once" if @swarm_started
292
+
293
+ @swarm_started = true
294
+ end
295
+ end
296
+
297
+ def check_swarm_control!(token, deadline, step)
298
+ token.raise_if_cancelled!
299
+ raise DeadlineExceededError, "The run deadline was reached" if deadline && Time.now >= deadline
300
+ if step >= self.class.max_steps
301
+ raise AssemblyLimitError, "#{self.class} reached its max_steps limit of #{self.class.max_steps}"
302
+ end
303
+ end
304
+
305
+ def allowed_targets(current, members, topology)
306
+ return members.keys.reject { |id| id == current } if topology.empty?
307
+
308
+ topology.find { |declaration| declaration.from == current }&.to || []
309
+ end
310
+
311
+ def handoff_tool_for(current, allowed, members)
312
+ descriptions = allowed.map do |id|
313
+ member = members.fetch(id)
314
+ description = member_description(member.agent)
315
+ description.empty? ? id : "#{id}: #{description}"
316
+ end.join("; ")
317
+ Class.new(Tool) do
318
+ tool_name "handoff_to_agent"
319
+ description "Hand the request to one available swarm member: #{descriptions}"
320
+ input_schema(
321
+ type: "object",
322
+ properties: {
323
+ agent_id: {type: "string", enum: allowed},
324
+ message: {type: "string"},
325
+ context: {type: "object", additionalProperties: true}
326
+ },
327
+ required: %w[agent_id message],
328
+ additionalProperties: false
329
+ )
330
+
331
+ define_method(:call) do |input|
332
+ payload = {
333
+ agent_id: input.fetch("agent_id"),
334
+ message: input.fetch("message"),
335
+ context: input["context"]
336
+ }.compact.freeze
337
+ agent.request_assembly_transition(payload, context: context)
338
+ "Handing off to #{payload.fetch(:agent_id)}."
339
+ end
340
+ end
341
+ end
342
+
343
+ def member_description(reference)
344
+ return reference.description if reference.respond_to?(:description)
345
+ return reference.implementation.description if reference.is_a?(AssemblyDefinition)
346
+
347
+ ""
348
+ end
349
+
350
+ def active_transition(execution, allowed)
351
+ transition = execution.transition
352
+ return nil unless transition
353
+ unless transition.is_a?(Hash)
354
+ raise ProtocolError, "swarm member requested a handoff without a transition payload"
355
+ end
356
+ target = transition[:agent_id] || transition["agent_id"]
357
+ raise AssemblyRoutingError, "swarm member selected unavailable target #{target.inspect}" unless allowed.include?(target)
358
+
359
+ {agent_id: target, message: transition[:message] || transition["message"], context: transition[:context] || transition["context"]}.compact
360
+ end
361
+
362
+ def sanitized_handoff_step(step, transition)
363
+ output, truncated = projected_step_output(transition)
364
+ Step.new(**step.to_h.merge(output:, output_truncated: truncated))
365
+ end
366
+
367
+ def step_error_usage(error) = error.instance_variable_get(:@little_ghost_step_usage) || Usage.new
368
+
369
+ def handoff_input(from:, transition:)
370
+ text = "Handoff from #{from}:\n#{transition.fetch(:message)}"
371
+ if transition[:context]
372
+ text << "\n\nAdditional context supplied by the previous agent:\n"
373
+ text << JSON.generate(transition.fetch(:context))
374
+ end
375
+ Message.new(role: :user, content: text)
376
+ rescue JSON::GeneratorError
377
+ raise AssemblyRoutingError, "swarm handoff context must be JSON-compatible"
378
+ end
379
+
380
+ def release_final_events(events, final)
381
+ buffer = []
382
+ bytes = 0
383
+ events.each do |event|
384
+ event = StreamEvent.build(event.type, **event.data.merge(result: final)) if event.type == :invocation_stop
385
+ bytes = buffer_event!(buffer, event, bytes:)
386
+ end
387
+ buffer
388
+ end
389
+
390
+ def buffer_event!(buffer, event, bytes:)
391
+ raise AssemblyLimitError, "swarm member emitted too many buffered events" if buffer.length >= MAX_BUFFERED_EVENTS
392
+
393
+ bytes += buffered_size(event)
394
+ raise AssemblyLimitError, "swarm member emitted too much buffered event data" if bytes > MAX_BUFFERED_EVENT_BYTES
395
+
396
+ buffer << event
397
+ bytes
398
+ end
399
+
400
+ def buffered_size(value, ancestors = {}, depth = 0)
401
+ return MAX_BUFFERED_EVENT_BYTES + 1 if depth > 32
402
+
403
+ identity = value.object_id
404
+ return 0 if ancestors.key?(identity)
405
+
406
+ case value
407
+ when String
408
+ value.bytesize
409
+ when Hash
410
+ ancestors[identity] = true
411
+ value.sum { |key, item| buffered_size(key, ancestors, depth + 1) + buffered_size(item, ancestors, depth + 1) }
412
+ when Array
413
+ ancestors[identity] = true
414
+ value.sum { |item| buffered_size(item, ancestors, depth + 1) }
415
+ when Data
416
+ ancestors[identity] = true
417
+ value.members.sum { |member| buffered_size(value.public_send(member), ancestors, depth + 1) }
418
+ else
419
+ 64
420
+ end
421
+ ensure
422
+ ancestors.delete(identity) if identity
423
+ end
424
+
425
+ def normalize_history(value)
426
+ return [].freeze if value.nil?
427
+
428
+ Array(value).map { |message| Message.coerce(message) }.freeze
429
+ end
430
+ end
431
+ end
@@ -76,9 +76,16 @@ module LittleGhost
76
76
  # Report the caller-safe outcome of one tool execution.
77
77
  # The result retains model-facing content, status, and the original exception
78
78
  # for application-side inspection.
79
- ExecutionResult = Data.define(:content, :status, :error) do # :nodoc:
80
- def initialize(content:, status:, error: nil)
81
- super
79
+ ExecutionResult = Data.define(:content, :status, :error, :companion_content) do # :nodoc:
80
+ def initialize(content:, status:, error: nil, companion_content: [])
81
+ companions = Array(companion_content)
82
+ unless companions.all? do |block|
83
+ block.is_a?(Content::Text) || block.is_a?(Content::Image) || block.is_a?(Content::Document)
84
+ end
85
+ raise ArgumentError, "companion content must contain only text, images, or documents"
86
+ end
87
+
88
+ super(content:, status:, error:, companion_content: companions.dup.freeze)
82
89
  end
83
90
 
84
91
  def success?
@@ -92,12 +99,14 @@ module LittleGhost
92
99
 
93
100
  # Reports the caller-safe outcome of one tool execution. It keeps
94
101
  # model-facing content and status beside the original exception retained for
95
- # application-side inspection.
102
+ # application-side inspection. Companion content lets a tool place trusted
103
+ # text, images, or documents in the next model request without putting those
104
+ # blocks inside a provider-specific tool-result shape.
96
105
  class ExecutionResult < Data # :doc:
97
106
  ##
98
107
  # :singleton-method: new
99
108
  # :call-seq:
100
- # new(content:, status:, error: nil) -> ExecutionResult
109
+ # new(content:, status:, error: nil, companion_content: []) -> ExecutionResult
101
110
  #
102
111
  # Collects the model-facing and application-facing parts of one result.
103
112
 
@@ -113,6 +122,11 @@ module LittleGhost
113
122
  # :attr_reader: error
114
123
  # The original exception for application-side inspection, when present.
115
124
 
125
+ ##
126
+ # :attr_reader: companion_content
127
+ # Frozen Text, Image, and Document blocks appended as a transient user
128
+ # message after the ordinary tool result.
129
+
116
130
  ##
117
131
  # :method: success?
118
132
  # Indicates that +status+ is +:success+.
@@ -292,7 +306,10 @@ module LittleGhost
292
306
  return failure(message, error: ToolError.new(message))
293
307
  end
294
308
 
295
- success(sanitize(bound_for(context).call(input)))
309
+ value = bound_for(context).call(input)
310
+ return normalize_execution_result(value) if value.is_a?(ExecutionResult)
311
+
312
+ success(sanitize(value))
296
313
  rescue CancelledError, DeadlineExceededError, CleanupError
297
314
  raise
298
315
  rescue ToolError => error
@@ -345,6 +362,15 @@ module LittleGhost
345
362
  ExecutionResult.new(content: content.freeze, status: :error, error:)
346
363
  end
347
364
 
365
+ def normalize_execution_result(result)
366
+ ExecutionResult.new(
367
+ content: sanitize(result.content).freeze,
368
+ status: result.status,
369
+ error: result.error,
370
+ companion_content: result.companion_content
371
+ )
372
+ end
373
+
348
374
  class SchemaValidator # :nodoc:
349
375
  def initialize(schema)
350
376
  @schema = schema
@@ -42,6 +42,8 @@ module LittleGhost
42
42
  tool_type: "gen_ai.tool.type",
43
43
  tool_call_id: "gen_ai.tool.call.id",
44
44
  workflow_name: "gen_ai.workflow.name",
45
+ assembly_id: "little_ghost.assembly.id",
46
+ assembly_kind: "little_ghost.assembly.kind",
45
47
  http_response_status_code: "http.response.status_code",
46
48
  error_class: "error.type",
47
49
  error_type: "error.type"
@@ -54,8 +56,12 @@ module LittleGhost
54
56
  runtime: "runtime_startup",
55
57
  session_store: "session_store",
56
58
  subagent: "invoke_agent",
59
+ assembly_step: "execute_assembly_step",
57
60
  tool: "execute_tool",
58
- workflow: "invoke_workflow"
61
+ workflow: "invoke_workflow",
62
+ swarm: "invoke_swarm",
63
+ graph: "invoke_graph",
64
+ assembly: "invoke_assembly"
59
65
  }.freeze # :nodoc:
60
66
  REQUEST_SETTING_ATTRIBUTES = {
61
67
  frequency_penalty: "gen_ai.request.frequency_penalty",
@@ -151,7 +157,10 @@ module LittleGhost
151
157
 
152
158
  def start_span(name, attributes)
153
159
  kind = name.to_sym
154
- kind = :workflow if kind == :run && attributes[:entrypoint_kind]&.to_sym == :workflow
160
+ if kind == :run
161
+ entrypoint_kind = attributes[:entrypoint_kind]&.to_sym
162
+ kind = entrypoint_kind if %i[workflow swarm graph assembly].include?(entrypoint_kind)
163
+ end
155
164
  parent = @mutex.synchronize { @entries[attributes[:parent_operation_id]] }
156
165
  if kind == :agent && parent&.fetch(:kind) == :run &&
157
166
  parent[:agent_id].to_s == attributes[:agent_id].to_s
@@ -258,6 +267,8 @@ module LittleGhost
258
267
  detail = case kind
259
268
  when :agent, :run then attributes[:agent_name] || attributes[:agent_id]
260
269
  when :workflow then attributes[:workflow_name]
270
+ when :swarm, :graph, :assembly then attributes[:assembly_id]
271
+ when :assembly_step then attributes[:participant]
261
272
  when :agent_turn then attributes[:turn]
262
273
  when :model then attributes[:model_id]
263
274
  when :subagent then attributes[:subagent_id] || attributes[:kind]
@@ -146,7 +146,7 @@ module LittleGhost
146
146
  environment:,
147
147
  inherit_environment:
148
148
  )
149
- Execution.new(stdout:, stderr:, exit_code: status.exitstatus)
149
+ Sandbox::Execution.new(stdout:, stderr:, exit_code: status.exitstatus)
150
150
  end
151
151
 
152
152
  private
@@ -2,5 +2,5 @@
2
2
 
3
3
  module LittleGhost
4
4
  # Current LittleGhost gem version.
5
- VERSION = "0.2.0"
5
+ VERSION = "0.3.0"
6
6
  end