Class: LittleGhost::Assembly

Inherits:
Object
  • Object
show all
Extended by:
Support::ClassAttributes
Defined in:
lib/little_ghost/assembly.rb,
lib/little_ghost/assembly_execution.rb

Overview

Gives one agent or a coordinated group the same callable entrypoint.

An assembly is anything callers can invoke like one Agent. An Agent is the smallest assembly because it owns one model loop. Workflow, Swarm, and Graph subclasses coordinate several participants while preserving the same ask, stream_ask, call, and stream interface.

agent_run = CustomerSupportAgent.ask("Why is my transfer pending?")
graph_run = SupportFlowGraph.ask("Why is my transfer pending?")

agent_run.response
graph_run.response

Applications normally subclass Agent, Workflow, Swarm, or Graph rather than Assembly directly. Each standalone call returns a top-level Run. Participants called inside another Assembly return a RunResult to their parent.

Advanced construction

Named subclasses are the usual form. to_builder creates a mutable definition for applications that discover participants at runtime. definition returns the fixed snapshot used by one execution. Composite results record their steps in RunResult#trajectory.

Return values

[CustomerSupportAgent.ask(...)] A named class creates and returns a top-level Run. [CustomerSupportAgent.new(runtime: runtime).ask(...)] A standalone instance also creates and returns a top-level Run. [runtime.build_assembly(..., run: run).call(...)] A participant already bound to a Run returns its child RunResult. [stream_ask(...).each { |event| ... }] A standalone stream returns its top-level Run after enumeration. A run-scoped stream ends with an invocation_stop event carrying RunResult.

Direct Known Subclasses

Agent, Graph, Swarm, Workflow

Defined Under Namespace

Classes: Attempt, Step, StepExecution, Trajectory

Constant Summary collapse

MAX_STEP_OUTPUT_BYTES =

:nodoc:

64 * 1024
MAX_STEP_EVENTS =

:nodoc:

10_000
MAX_STEP_EVENT_BYTES =

:nodoc:

10 * 1024 * 1024

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Support::ClassAttributes

class_attribute, included

Constructor Details

#initialize(run: nil, runtime: nil, workspace: nil, sandbox: nil, standalone: run.nil?) ⇒ Assembly

:nodoc:



156
157
158
159
160
161
162
163
164
165
166
167
# File 'lib/little_ghost/assembly.rb', line 156

def initialize(run: nil, runtime: nil, workspace: nil, sandbox: nil, standalone: run.nil?) # :nodoc:
  @run = run
  @runtime = runtime || run&.runtime || LittleGhost.runtime
  @workspace = workspace || run&.workspace
  @sandbox = sandbox || run&.sandbox
  @assembly_definition = nil
  @standalone = standalone
  @assembly_mutex = Mutex.new
  @assembly_closed = false
  @active_assemblies = []
  @agent_stream_path = [].freeze
end

Instance Attribute Details

#agent_stream_pathObject (readonly)

:nodoc:



154
155
156
# File 'lib/little_ghost/assembly.rb', line 154

def agent_stream_path
  @agent_stream_path
end

#runObject (readonly)

The owning Run, or nil for a standalone entrypoint.



147
148
149
# File 'lib/little_ghost/assembly.rb', line 147

def run
  @run
end

#runtimeObject (readonly)

Runtime used to resolve participants and build Runs.



149
150
151
# File 'lib/little_ghost/assembly.rb', line 149

def runtime
  @runtime
end

#sandboxObject (readonly)

Sandbox supplied to this Assembly, when present.



153
154
155
# File 'lib/little_ghost/assembly.rb', line 153

def sandbox
  @sandbox
end

#workspaceObject (readonly)

Workspace supplied to this Assembly, when present.



151
152
153
# File 'lib/little_ghost/assembly.rb', line 151

def workspace
  @workspace
end

Class Method Details

.ask(message, **options) ⇒ Object

Executes message through a fresh standalone assembly and returns its Run.

options become Invocation fields. Common values include history, context, settings, metadata, session_id, actor_id, and deadline_at.



52
53
54
55
# File 'lib/little_ghost/assembly.rb', line 52

def ask(message, **options)
  snapshot = definition
  snapshot.implementation.new.bind_assembly_definition(snapshot).ask(message, **options)
end

.assembly_id(*values) ⇒ Object

:call-seq:

assembly_id() -> String
assembly_id(value) -> String

The stable identifier used for tools and telemetry. Named subclasses derive it from their underscored class name without their type suffix.



110
111
112
113
114
# File 'lib/little_ghost/assembly.rb', line 110

def assembly_id(*values)
  return assembly_id_value || default_assembly_id if values.empty?

  self.assembly_id_value = values.fetch(0).to_s
end

.assembly_kindObject

Returns :agent, :workflow, :swarm, :graph, or :assembly.



128
129
130
131
132
133
134
135
# File 'lib/little_ghost/assembly.rb', line 128

def assembly_kind
  return :agent if defined?(Agent) && self <= Agent
  return :workflow if defined?(Workflow) && self <= Workflow
  return :swarm if defined?(Swarm) && self <= Swarm
  return :graph if defined?(Graph) && self <= Graph

  :assembly
end

.definitionObject

Returns an immutable definition for this class.



76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
# File 'lib/little_ghost/assembly.rb', line 76

def definition
  if assembly_kind == :assembly
    implementation = dup
    implementation.assembly_id(assembly_id)
    implementation.description(description)
    implementation.freeze
    return AssemblyDefinition.new(
      kind: :assembly,
      assembly_id:,
      description:,
      implementation:
    )
  end

  to_builder.definition
end

.description(*values) ⇒ Object

:call-seq:

description() -> String
description(value) -> String

The human-readable description used when exposing the assembly as a tool.



121
122
123
124
125
# File 'lib/little_ghost/assembly.rb', line 121

def description(*values)
  return description_value.to_s if values.empty?

  self.description_value = values.fetch(0).to_s
end

.stream_ask(message, **options) ⇒ Object

Lazily streams message through a fresh standalone assembly.

Enumeration yields StreamEvent objects and returns the terminal Run. The same Invocation fields accepted by .ask may be supplied as options. Composite assemblies also emit an :agent_stream event for every normalized event from every Agent in the run, including intermediate and nested participants. Set include_agent_events: false to keep only the ordinary public stream. A standalone Agent retains its ordinary stream by default and accepts true to opt in.



66
67
68
69
70
71
72
73
# File 'lib/little_ghost/assembly.rb', line 66

def stream_ask(message, **options)
  snapshot = definition
  stream = nil
  Enumerator.new do |events|
    stream ||= snapshot.implementation.new.bind_assembly_definition(snapshot).stream_ask(message, **options)
    stream.each { |event| events << event }
  end
end

.to_builderObject

Returns a mutable dynamic builder seeded by this class.



94
95
96
97
98
99
100
101
102
# File 'lib/little_ghost/assembly.rb', line 94

def to_builder
  builder_class = {
    agent: AgentBuilder,
    workflow: WorkflowBuilder,
    swarm: SwarmBuilder,
    graph: GraphBuilder
  }.fetch(assembly_kind)
  builder_class.new(base: self)
end

.validate_step_policy!(values) ⇒ Object

:nodoc:

Raises:

  • (ArgumentError)


352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
# File 'lib/little_ghost/assembly_execution.rb', line 352

def validate_step_policy!(values) # :nodoc:
  values = values.compact
  retries = Integer(values.fetch(:retries, 0))
  raise ArgumentError, "retries must be at least 0" if retries.negative?

  retry_on = Array(values[:retry_on])
  if retries.positive? && retry_on.empty?
    raise ArgumentError, "retry_on is required when retries is greater than 0"
  end
  unless retry_on.all? { |error| error.is_a?(Class) && error <= Exception }
    raise ArgumentError, "retry_on must contain exception classes"
  end
  timeout = Float(values[:timeout]) if values[:timeout]
  retry_delay = Float(values.fetch(:retry_delay, 0))
  raise ArgumentError, "timeout must be positive" if timeout && (!timeout.positive? || !timeout.finite?)
  raise ArgumentError, "retry_delay must be non-negative" if retry_delay.negative? || !retry_delay.finite?

  {retries:, retry_on: retry_on.freeze, timeout:, retry_delay:}.freeze
end

Instance Method Details

#as_tool(name: self.class.assembly_id, description: self.class.description, preserve_context: false) ⇒ Object

Exposes this assembly as a Tool instance.

By default, calls do not remember earlier conversation history. Set preserve_context: true to carry that history from one tool call to the next. This option does not control working state: every call receives the invoking Tool's current RunContext#state, which may include current request values or values restored from a Session. Nested tools must still authorize privileged work with current, application-established values.



259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
# File 'lib/little_ghost/assembly.rb', line 259

def as_tool(name: self.class.assembly_id, description: self.class.description, preserve_context: false)
  assembly = self
  description = "Delegate a task to #{name}." if description.to_s.empty?
  mutex = Mutex.new
  retained_history = []
  tool_class = Tool.define(
    name:,
    description:,
    input_schema: {
      type: "object",
      properties: {input: {type: "string"}},
      required: ["input"],
      additionalProperties: false
    }
  ) do |input, context: nil|
    invocation = lambda do
      target = if assembly.is_a?(Agent)
        assembly
      elsif assembly.run
        assembly.send(:build_tool_assembly)
      else
        assembly.class.new(runtime: assembly.runtime)
      end
      options = {
        history: preserve_context ? retained_history : [],
        context: context&.state || {},
        cancellation_token: context&.cancellation_token || Support::CancellationToken.new,
        deadline: context&.deadline,
        parent_operation_id: context&.agent_operation_id || assembly.run&.operation_id
      }
      if target.is_a?(Agent)
        options[:interjection_metadata] = context&.
        options[:interjection_ids] = context&.interjection_ids || []
      end
      result = target.call(input.fetch("input"), **options)
      if result.is_a?(Run)
        raise result.error if result.error

        result = result.result
      end
      raise ProtocolError, "assembly tool invocation did not return a result" unless result

      retained_history.replace(result.messages.reject { |message| message.role == :system }) if preserve_context
      result.structured? ? result.structured_result.value : result.text
    ensure
      target&.close unless target.equal?(assembly)
    end
    preserve_context ? mutex.synchronize(&invocation) : invocation.call
  end
  tool_class.define_method(:close) { assembly.close }
  tool_class.new(binding: Tool::Binding.new(
    agent: (self if is_a?(Agent)),
    run:,
    runtime:,
    model: (model if respond_to?(:model)),
    workspace:,
    sandbox:
  ))
end

#ask(message, **options) ⇒ Object

Runs message to completion.

A standalone instance returns its owning Run. A run-scoped instance returns the child RunResult.



225
226
227
# File 'lib/little_ghost/assembly.rb', line 225

def ask(message, **options)
  call(message, **options)
end

#bind_agent_stream_path(path) ⇒ Object

:nodoc:



169
170
171
172
# File 'lib/little_ghost/assembly.rb', line 169

def bind_agent_stream_path(path) # :nodoc:
  @agent_stream_path = Array(path).dup.freeze
  self
end

#bind_assembly_definition(definition) ⇒ Object

:nodoc:



174
175
176
177
# File 'lib/little_ghost/assembly.rb', line 174

def bind_assembly_definition(definition) # :nodoc:
  @assembly_definition = definition
  self
end

#build_run(payload = nil, include_agent_events_by_default: false, **payload_options) ⇒ Object

Builds the top-level Run used by a standalone assembly.



180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
# File 'lib/little_ghost/assembly.rb', line 180

def build_run(payload = nil, include_agent_events_by_default: false, **payload_options) # :nodoc:
  payload = entrypoint_payload(payload, payload_options) unless payload_options.empty?
  payload = payload.dup if payload.is_a?(Hash)
  cancellation_token = if payload.is_a?(Hash)
    payload.delete(:cancellation_token) || payload.delete("cancellation_token")
  end
  source_class = run_entrypoint_class
  options = {entrypoint_class: source_class}
  execution = @assembly_definition || self.class
  options[:execution_class] = execution unless source_class.equal?(execution)
  options[:agent_class] = source_class if is_a?(Agent)
  options[:cancellation_token] = cancellation_token if cancellation_token
  options[:workspace] = workspace if workspace
  options[:sandbox] = sandbox if sandbox
  options[:include_agent_events_by_default] = true if include_agent_events_by_default
  runtime.build_run(payload, **options)
end

#call(input = nil, **options) ⇒ Object

Runs input to completion.

A standalone assembly returns a Run. A run-scoped assembly returns its RunResult.



211
212
213
214
215
216
217
218
219
# File 'lib/little_ghost/assembly.rb', line 211

def call(input = nil, **options)
  return build_run(entrypoint_payload(input, options)).call if standalone?

  result = nil
  stream(input, **options).each do |event|
    result = event.data[:result] if event.type == :invocation_stop
  end
  result
end

#closeObject

Closes resources owned directly by this assembly.



335
336
337
338
339
340
341
# File 'lib/little_ghost/assembly.rb', line 335

def close
  @assembly_mutex.synchronize do
    return if @assembly_closed

    @assembly_closed = true
  end
end

#entrypoint_nameObject

:nodoc:



343
# File 'lib/little_ghost/assembly.rb', line 343

def entrypoint_name = self.class.assembly_id # :nodoc:

#interject(message, **options) ⇒ Object

Adds an interjection to the single active leaf Agent.



320
321
322
323
324
325
326
327
328
329
330
331
332
# File 'lib/little_ghost/assembly.rb', line 320

def interject(message, **options)
  child = @assembly_mutex.synchronize do
    active = @active_assemblies.dup
    if active.empty?
      raise AgentInterjectionError, "Assembly is not currently running"
    end
    if active.length > 1
      raise AgentInterjectionError, "Assembly has multiple active participants; the interjection target is ambiguous"
    end
    active.first
  end
  child.interject(message, **options)
end

#prompt_localsObject

Additional prompt locals made available to child agents.



346
# File 'lib/little_ghost/assembly.rb', line 346

def prompt_locals = {}

#start_execution(payload, &event_consumer) ⇒ Object

Starts payload in the background and returns an Execution. Composite assemblies include contextual :agent_stream events in the consumer by default. Set include_agent_events to false in payload to keep only the ordinary public stream.



202
203
204
205
# File 'lib/little_ghost/assembly.rb', line 202

def start_execution(payload, &event_consumer)
  ensure_standalone!
  Execution.start(build_stream_run(payload), &event_consumer)
end

#stream_ask(message, **options) ⇒ Object

Lazily streams message through the standalone or run-scoped assembly.

A standalone stream returns its terminal Run after enumeration. A run-scoped stream finishes with an invocation_stop event containing its RunResult. A standalone composite Assembly receives contextual :agent_stream events from every Agent in the Run by default and may set include_agent_events: false to omit them. A standalone Agent may set the option to true to include its contextual wrapper.



237
238
239
240
241
242
243
244
245
246
247
248
249
# File 'lib/little_ghost/assembly.rb', line 237

def stream_ask(message, **options)
  if standalone?
    options[:deadline_at] = options.delete(:deadline) if options.key?(:deadline)
    payload = entrypoint_payload(message, options)
    stream = nil
    return Enumerator.new do |events|
      stream ||= build_stream_run(payload).each
      stream.each { |event| events << event }
    end
  end

  stream(message, **options)
end