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

Named subclasses are the usual form. to_builder creates a mutable dynamic definition seeded by the class, while definition returns the immutable snapshot used for one execution. Composite results expose Assembly::Step records through RunResult#trajectory.

A standalone assembly owns a top-level Run. An assembly built by a Runtime participates in the existing run and returns a RunResult. Applications normally subclass Agent, Workflow, Swarm, or Graph rather than Assembly directly. Standalone calls automatically reuse the active Configuration's shared Runtime while keeping each Run and its resources independent.

What each calling form returns

[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:



148
149
150
151
152
153
154
155
156
157
# File 'lib/little_ghost/assembly.rb', line 148

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 if run&.respond_to?(:workspace))
  @sandbox = sandbox || (run.sandbox if run&.respond_to?(:sandbox))
  @standalone = standalone
  @assembly_mutex = Mutex.new
  @assembly_closed = false
  @active_assemblies = []
end

Instance Attribute Details

#runObject (readonly)

The owning Run, or nil for a standalone entrypoint.



140
141
142
# File 'lib/little_ghost/assembly.rb', line 140

def run
  @run
end

#runtimeObject (readonly)

Runtime used to resolve participants and build Runs.



142
143
144
# File 'lib/little_ghost/assembly.rb', line 142

def runtime
  @runtime
end

#sandboxObject (readonly)

Sandbox supplied to this Assembly, when present.



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

def sandbox
  @sandbox
end

#workspaceObject (readonly)

Workspace supplied to this Assembly, when present.



144
145
146
# File 'lib/little_ghost/assembly.rb', line 144

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.



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

def ask(message, **options)
  definition.implementation.new.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.



103
104
105
106
107
# File 'lib/little_ghost/assembly.rb', line 103

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.



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

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.



69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
# File 'lib/little_ghost/assembly.rb', line 69

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.



114
115
116
117
118
# File 'lib/little_ghost/assembly.rb', line 114

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.



59
60
61
62
63
64
65
66
# File 'lib/little_ghost/assembly.rb', line 59

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

.to_builderObject

Returns a mutable dynamic builder seeded by this class.



87
88
89
90
91
92
93
94
95
# File 'lib/little_ghost/assembly.rb', line 87

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)


337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
# File 'lib/little_ghost/assembly_execution.rb', line 337

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.



229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
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
# File 'lib/little_ghost/assembly.rb', line 229

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.runtime.build_assembly(assembly.class, run: assembly.run)
      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: 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.



199
200
201
# File 'lib/little_ghost/assembly.rb', line 199

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

#build_run(payload) ⇒ Object

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



160
161
162
163
164
165
166
167
168
169
170
171
172
173
# File 'lib/little_ghost/assembly.rb', line 160

def build_run(payload) # :nodoc:
  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 = self.class.respond_to?(:assembly_source_class) ? self.class.assembly_source_class : self.class
  options = {entrypoint_class: source_class}
  options[:execution_class] = self.class unless source_class.equal?(self.class)
  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
  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.



185
186
187
188
189
190
191
192
193
# File 'lib/little_ghost/assembly.rb', line 185

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.



305
306
307
308
309
310
311
# File 'lib/little_ghost/assembly.rb', line 305

def close
  @assembly_mutex.synchronize do
    return if @assembly_closed

    @assembly_closed = true
  end
end

#entrypoint_nameObject

:nodoc:



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

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

#interject(message, **options) ⇒ Object

Adds an interjection to the single active leaf Agent.



290
291
292
293
294
295
296
297
298
299
300
301
302
# File 'lib/little_ghost/assembly.rb', line 290

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.



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

def prompt_locals = {}

#start_execution(payload, &event_consumer) ⇒ Object

Starts payload on a supervised worker and returns an Execution.



176
177
178
179
# File 'lib/little_ghost/assembly.rb', line 176

def start_execution(payload, &event_consumer)
  ensure_standalone!
  Execution.start(build_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.



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

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

  stream(message, **options)
end