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

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.

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:



117
118
119
120
121
122
123
124
125
126
# File 'lib/little_ghost/assembly.rb', line 117

def initialize(run: nil, runtime: nil, workspace: nil, sandbox: nil, standalone: run.nil?) # :nodoc:
  @run = run
  @runtime = runtime || run&.runtime || Runtime.new(configuration: LittleGhost.configuration)
  @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, runtime, and optional standalone resources.



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

def run
  @run
end

#runtimeObject (readonly)

The owning run, runtime, and optional standalone resources.



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

def runtime
  @runtime
end

#sandboxObject (readonly)

The owning run, runtime, and optional standalone resources.



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

def sandbox
  @sandbox
end

#workspaceObject (readonly)

The owning run, runtime, and optional standalone resources.



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

def workspace
  @workspace
end

Class Method Details

.ask(message, **options) ⇒ Object

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



29
30
31
# File 'lib/little_ghost/assembly.rb', line 29

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.



78
79
80
81
82
# File 'lib/little_ghost/assembly.rb', line 78

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.



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

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.



44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
# File 'lib/little_ghost/assembly.rb', line 44

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.



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

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.



34
35
36
37
38
39
40
41
# File 'lib/little_ghost/assembly.rb', line 34

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.



62
63
64
65
66
67
68
69
70
# File 'lib/little_ghost/assembly.rb', line 62

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)


254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
# File 'lib/little_ghost/assembly_execution.rb', line 254

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 each call has empty conversational history. Set preserve_context: true to retain history serially. Every call still receives the invoking Tool::Context application state; preserve_context does not suppress it. Tools inside the assembly remain responsible for authorizing privileged work from trusted context.



190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
# File 'lib/little_ghost/assembly.rb', line 190

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[:interruption_metadata] = context&.
        options[:interruption_ids] = context&.interruption_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.



165
166
167
# File 'lib/little_ghost/assembly.rb', line 165

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

#build_run(payload) ⇒ Object

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



129
130
131
132
133
134
135
136
137
138
139
140
141
142
# File 'lib/little_ghost/assembly.rb', line 129

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.



154
155
156
157
158
159
160
161
162
# File 'lib/little_ghost/assembly.rb', line 154

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.



271
272
273
274
275
276
277
# File 'lib/little_ghost/assembly.rb', line 271

def close
  @assembly_mutex.synchronize do
    return if @assembly_closed

    @assembly_closed = true
  end
end

#entrypoint_nameObject

:nodoc:



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

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

#interrupt(message, **options) ⇒ Object

Adds message to the single active leaf Agent and returns its text reply.



251
252
253
# File 'lib/little_ghost/assembly.rb', line 251

def interrupt(message, **options)
  interrupt_response(message, **options).text
end

#interrupt_response(message, **options) ⇒ Object

Adds an interruption to the single active leaf Agent.



256
257
258
259
260
261
262
263
264
265
266
267
268
# File 'lib/little_ghost/assembly.rb', line 256

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

#prompt_localsObject

Additional prompt locals made available to child agents.



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

def prompt_locals = {}

#start_execution(payload, &event_consumer) ⇒ Object

Starts payload on a supervised worker and returns an Execution.



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

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.



170
171
172
173
174
175
176
177
178
179
180
181
# File 'lib/little_ghost/assembly.rb', line 170

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