Class: LittleGhost::Run

Inherits:
Object
  • Object
show all
Includes:
Enumerable
Defined in:
lib/little_ghost/run.rb

Overview

Observe one top-level assembly execution from start to finish. A run records its response, outcome, usage, error, and owned resources.

run = CustomerSupportAgent.ask("Why is transfer 481 pending?")

run.completed? # => true
run.outcome    # => "completed"
run.response   # => "Transfer 481 is waiting for the receiving bank."

ask returns the Run after work finishes. stream_ask yields StreamEvent objects as work happens, then returns the same finished Run. A Run executes only once.

stream = CustomerSupportAgent.stream_ask("Where is transfer 481?")
run = stream.each do |event|
publish(event) if event.type == :text_delta
end

run.completed? # => true
run.response

Outcomes

Completion, failure, deadline, and cancellation become the completed, failed, partial, and cancelled outcomes. Ordinary execution failures are available through error and the terminal stream event. Failures while closing resources, delivering events, or reporting instrumentation may still raise because LittleGhost cannot report a reliable ending.

Tool validation and ToolError failures return safe Tool results to the model, which may recover and complete the Run. Input, configuration, or resource construction can raise before a Run exists. Once execution begins, terminal events are run_stop, run_error, run_partial, and run_cancel.

Owned resources

The Run opens its workspace, sandbox, Session, and Assembly entrypoint, then closes registered resources in reverse order. register adds application resources to that cleanup sequence. Interjection is available only while one Agent entrypoint is active.

Nested Agent events

A composite Assembly stream observes every Agent that shares the Run. Each :agent_stream event carries an AgentStreamSource in data[:source] and a copied, frozen Agent StreamEvent in data[:event]. An inner :invocation_start also includes the copied, frozen Message sent to that Agent in data[:input]. Event consumers cannot change the running work.

Parallel Agents may interleave, but the Run invokes the stream consumer serially. Contextual events expose data from every participating Agent, so applications should enable include_agent_events only for destinations that may see every participant's data.

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(invocation:, runtime:, agent_class: nil, assembly_class: nil, entrypoint_class: nil, execution_class: nil, cancellation_token: Support::CancellationToken.new, workspace: nil, sandbox: nil, include_agent_events_by_default: false) ⇒ Run

Creates a dormant run for invocation.

Raises:

  • (ArgumentError)


92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
# File 'lib/little_ghost/run.rb', line 92

def initialize(invocation:, runtime:, agent_class: nil, assembly_class: nil, entrypoint_class: nil,
  execution_class: nil,
  cancellation_token: Support::CancellationToken.new, workspace: nil, sandbox: nil,
  include_agent_events_by_default: false)
  entrypoint_class ||= assembly_class || agent_class
  raise ArgumentError, "entrypoint_class is required" unless entrypoint_class
  execution_class ||= assembly_class || entrypoint_class

  @runtime = runtime
  @agent_class = agent_class
  @entrypoint_class = entrypoint_class
  @execution_class = execution_class
  @invocation = invocation
  @cancellation_token = cancellation_token
  @workspace = workspace
  @sandbox = sandbox
  @operation_id = SecureRandom.uuid
  @resources = []
  @shared_resources = {}
  @shared_resource_condition = ConditionVariable.new
  @closed = false
  @started = false
  @mutex = Mutex.new
  @event_mutex = Mutex.new
  @subagent_instrumentation_mutex = Mutex.new
  @subagent_instrumentation = {}
  @assembly_step_instrumentation_mutex = Mutex.new
  @assembly_step_instrumentation = {}
  @exclusive_tools_mutex = Mutex.new
  @once_mutex = Mutex.new
  @once_keys = {}
  @interjection_mutex = Mutex.new
  @interjection_condition = ConditionVariable.new
  @interjection_state = :not_started
  @active_interjections = 0
  @entrypoint = nil
  @usage = Usage.new
  include_agent_events = invocation[:include_agent_events]
  unless include_agent_events.nil? || include_agent_events == true || include_agent_events == false
    raise InvocationError, "include_agent_events must be true or false"
  end
  @include_agent_events = include_agent_events.nil? ? include_agent_events_by_default : include_agent_events
end

Instance Attribute Details

#agent_classObject (readonly)

Agent class used for compatibility when the entrypoint is an Agent.



65
66
67
# File 'lib/little_ghost/run.rb', line 65

def agent_class
  @agent_class
end

#cancellation_tokenObject (readonly)

Token that cooperatively stops this Run and its children.



71
72
73
# File 'lib/little_ghost/run.rb', line 71

def cancellation_token
  @cancellation_token
end

#entrypoint_classObject (readonly)

Public Agent, Workflow, Swarm, or Graph class selected by the caller.



67
68
69
# File 'lib/little_ghost/run.rb', line 67

def entrypoint_class
  @entrypoint_class
end

#errorObject (readonly)

Exception that caused a failed, partial, or cancelled outcome.



81
82
83
# File 'lib/little_ghost/run.rb', line 81

def error
  @error
end

#invocationObject (readonly)

Normalized request carried by this Run.



69
70
71
# File 'lib/little_ghost/run.rb', line 69

def invocation
  @invocation
end

#operation_idObject (readonly)

Unique identifier for this top-level operation.



75
76
77
# File 'lib/little_ghost/run.rb', line 75

def operation_id
  @operation_id
end

#outcomeObject (readonly)

Terminal String: completed, failed, partial, or cancelled.



77
78
79
# File 'lib/little_ghost/run.rb', line 77

def outcome
  @outcome
end

#responseObject (readonly)

Caller-facing final text, or the partial text preserved at a deadline.



79
80
81
# File 'lib/little_ghost/run.rb', line 79

def response
  @response
end

#resultObject (readonly)

Final RunResult, when the Assembly produced one.



73
74
75
# File 'lib/little_ghost/run.rb', line 73

def result
  @result
end

#runtimeObject (readonly)

Runtime that built and executes this Run.



63
64
65
# File 'lib/little_ghost/run.rb', line 63

def runtime
  @runtime
end

#sandboxObject (readonly)

Request-scoped sandbox owned or supplied by the Run.



89
90
91
# File 'lib/little_ghost/run.rb', line 89

def sandbox
  @sandbox
end

#sessionObject (readonly)

Session opened for this invocation, when persistence is configured.



83
84
85
# File 'lib/little_ghost/run.rb', line 83

def session
  @session
end

#usageObject (readonly)

Normalized Usage accumulated by the Run.



85
86
87
# File 'lib/little_ghost/run.rb', line 85

def usage
  @usage
end

#workspaceObject (readonly)

Request-scoped workspace owned or supplied by the Run.



87
88
89
# File 'lib/little_ghost/run.rb', line 87

def workspace
  @workspace
end

Instance Method Details

#callObject

Consumes the event stream and returns self.



137
138
139
140
# File 'lib/little_ghost/run.rb', line 137

def call
  each { |_event| }
  self
end

#cancelled?Boolean

True when cancellation stopped the run without a response.

Returns:

  • (Boolean)


170
# File 'lib/little_ghost/run.rb', line 170

def cancelled? = outcome == "cancelled"

#closeObject

Closes registered resources in reverse order.

The operation is idempotent. It attempts every closer and then raises the first LittleGhost::CleanupError, or otherwise the first cleanup exception.



326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
# File 'lib/little_ghost/run.rb', line 326

def close
  callbacks = @mutex.synchronize do
    return if @closed
    @closed = true
    @shared_resources.clear
    @shared_resource_condition.broadcast
    @resources.reverse
  end
  errors = []
  callbacks.each do |callback|
    callback.call
  rescue => error
    errors << error
  end
  cleanup_error = errors.find { |caught| caught.is_a?(CleanupError) } || errors.first
  begin
    finish_remaining_subagent_instrumentation(
      outcome: cleanup_error ? :error : :cancelled,
      error_type: cleanup_error&.class&.name
    )
    finish_remaining_assembly_step_instrumentation(
      outcome: cleanup_error ? :error : :cancelled,
      error_type: cleanup_error&.class&.name
    )
  rescue => error
    errors << error
  end
  error = errors.find { |caught| caught.is_a?(CleanupError) } || errors.first
  raise error if error
end

#completed?Boolean

True after successful completion.

Returns:

  • (Boolean)


161
# File 'lib/little_ghost/run.rb', line 161

def completed? = outcome == "completed"

#context(state: {}, metadata: {}) ⇒ Object

Creates a RunContext with this run's cancellation token and deadline.



230
231
232
233
234
235
236
237
# File 'lib/little_ghost/run.rb', line 230

def context(state: {}, metadata: {})
  RunContext.new(
    state:,
    cancellation_token:,
    deadline: invocation.deadline_at,
    metadata:
  )
end

#eachObject

Yields events and returns self after the terminal event.

Without a block, returns an Enumerator. A second execution raises Error.



145
146
147
148
149
150
151
152
153
154
155
156
157
158
# File 'lib/little_ghost/run.rb', line 145

def each
  return enum_for(__method__) unless block_given?

  begin_execution!
  @emitter = lambda do |event|
    @event_mutex.synchronize { yield_event(event) { |value| yield value } }
  end
  Instrumentation.with_context(correlation_attributes.except(:operation_id)) do
    execute { |event| @emitter.call(event) }
  end
  self
ensure
  @emitter = nil
end

#failed?Boolean

True after execution or cleanup failed.

Returns:

  • (Boolean)


164
# File 'lib/little_ghost/run.rb', line 164

def failed? = outcome == "failed"

#include_agent_events?Boolean

Indicates whether the stream includes contextual events from every Agent that executes as part of this Run.

Returns:

  • (Boolean)


174
# File 'lib/little_ghost/run.rb', line 174

def include_agent_events? = @include_agent_events

#interject(message, interjection_id: nil, batch_key: nil, metadata: {}, cancellation_token: Support::CancellationToken.new, deadline: nil) ⇒ Object

Adds an interjection to the active entrypoint and waits for its response.

Raises LittleGhost::AgentInterjectionError before the entrypoint is ready, after it finishes, or when the entrypoint does not support interjections.



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

def interject(
  message,
  interjection_id: nil,
  batch_key: nil,
  metadata: {},
  cancellation_token: Support::CancellationToken.new,
  deadline: nil
)
  interject_with do
    [
      message,
      {
        interjection_id:,
        batch_key:,
        metadata:,
        cancellation_token:,
        deadline:
      }
    ]
  end
end

#interject_withObject

:nodoc:



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
# File 'lib/little_ghost/run.rb', line 202

def interject_with # :nodoc:
  entrypoint = @interjection_mutex.synchronize do
    case @interjection_state
    when :not_started, :starting
      raise AgentInterjectionError, "Run entrypoint is not ready for interjections"
    when :terminal
      raise AgentInterjectionError, "Run has already finished"
    end

    @active_interjections += 1
    @entrypoint
  end
  unless entrypoint.respond_to?(:interject)
    raise AgentInterjectionError, "Run entrypoint does not support interjections"
  end

  message, options = yield
  entrypoint.interject(message, **options)
ensure
  if entrypoint
    @interjection_mutex.synchronize do
      @active_interjections -= 1
      @interjection_condition.broadcast
    end
  end
end

#once(key) ⇒ Object

Performs the block at most once successfully for key during this run.

Concurrent callers are serialized. The caller that performs the block receives its value; later callers receive nil. If the block raises, the key is not recorded and a later call may retry it.



308
309
310
311
312
313
314
315
316
# File 'lib/little_ghost/run.rb', line 308

def once(key)
  @once_mutex.synchronize do
    return if @once_keys.key?(key)

    value = yield
    @once_keys[key] = true
    value
  end
end

#partial?Boolean

True when the deadline preserved a partial response.

Returns:

  • (Boolean)


167
# File 'lib/little_ghost/run.rb', line 167

def partial? = outcome == "partial"

#prepare_interjection(payload) ⇒ Object

:nodoc:



318
319
320
# File 'lib/little_ghost/run.rb', line 318

def prepare_interjection(payload) # :nodoc:
  runtime.prepare_interjection(self, payload)
end

#publish(type, **data) ⇒ Object

:nodoc:



239
240
241
242
243
244
# File 'lib/little_ghost/run.rb', line 239

def publish(type, **data) # :nodoc:
  event = StreamEvent.build(type, **data)
  @emitter&.call(event)
  instrument_event(type, data)
  event
end

#register(resource = nil, &closer) ⇒ Object

Adds a resource or closer to reverse-order cleanup and returns the resource.

A resource must respond to close unless a block supplies the cleanup operation. Registering after the run has closed raises Error.



250
251
252
253
254
255
256
257
# File 'lib/little_ghost/run.rb', line 250

def register(resource = nil, &closer)
  callback = closer || close_callback(resource)
  @mutex.synchronize do
    raise Error, "run is already closed" if @closed
    @resources << callback
  end
  resource
end

#shared_resource(key) ⇒ Object

:nodoc:



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
# File 'lib/little_ghost/run.rb', line 259

def shared_resource(key) # :nodoc:
  raise ArgumentError, "shared_resource requires a block" unless block_given?

  entry = @mutex.synchronize do
    loop do
      raise Error, "run is already closed" if @closed

      existing = @shared_resources[key]
      if existing
        return existing.fetch(:value) if existing[:ready]

        @shared_resource_condition.wait(@mutex)
        next
      end

      created = {ready: false, value: nil}
      @shared_resources[key] = created
      break created
    end
  end

  value = yield
  @mutex.synchronize do
    unless @shared_resources[key].equal?(entry) && !@closed
      raise Error, "run closed while a shared resource was starting"
    end

    entry[:value] = value
    entry[:ready] = true
    @shared_resource_condition.broadcast
  end
  value
rescue
  @mutex.synchronize do
    @shared_resources.delete(key) if @shared_resources[key].equal?(entry)
    @shared_resource_condition.broadcast
  end
  raise
end

#synchronize_exclusive_tools(&block) ⇒ Object

:nodoc:



299
300
301
# File 'lib/little_ghost/run.rb', line 299

def synchronize_exclusive_tools(&block) # :nodoc:
  @exclusive_tools_mutex.synchronize(&block)
end