little_ghost 0.2.1 → 0.4.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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +72 -74
  3. data/docs/guides/assemblies.md +286 -0
  4. data/docs/guides/core_concepts.md +159 -135
  5. data/docs/guides/getting_started.md +114 -83
  6. data/docs/guides/production.md +187 -0
  7. data/docs/guides/prompt_views.md +132 -0
  8. data/lib/little_ghost/ag_ui/adapter.rb +3 -3
  9. data/lib/little_ghost/agent/delegation.rb +35 -8
  10. data/lib/little_ghost/agent/tool_loop.rb +2 -1
  11. data/lib/little_ghost/agent.rb +280 -326
  12. data/lib/little_ghost/agent_builder.rb +20 -4
  13. data/lib/little_ghost/agent_factory.rb +3 -0
  14. data/lib/little_ghost/{agent_interruptions.rb → agent_interjections.rb} +12 -12
  15. data/lib/little_ghost/assembly.rb +345 -0
  16. data/lib/little_ghost/assembly_builder.rb +497 -0
  17. data/lib/little_ghost/assembly_execution.rb +535 -0
  18. data/lib/little_ghost/configuration.rb +263 -39
  19. data/lib/little_ghost/content.rb +5 -5
  20. data/lib/little_ghost/data_map.rb +209 -0
  21. data/lib/little_ghost/errors.rb +10 -2
  22. data/lib/little_ghost/execution.rb +206 -0
  23. data/lib/little_ghost/graph.rb +930 -0
  24. data/lib/little_ghost/message.rb +4 -4
  25. data/lib/little_ghost/model_resolver.rb +2 -2
  26. data/lib/little_ghost/prompt_resolver.rb +2 -0
  27. data/lib/little_ghost/run.rb +190 -64
  28. data/lib/little_ghost/run_context.rb +33 -20
  29. data/lib/little_ghost/run_result.rb +22 -11
  30. data/lib/little_ghost/runtime/hook.rb +9 -4
  31. data/lib/little_ghost/runtime.rb +134 -36
  32. data/lib/little_ghost/sandbox.rb +1 -1
  33. data/lib/little_ghost/session.rb +12 -23
  34. data/lib/little_ghost/session_store.rb +9 -5
  35. data/lib/little_ghost/session_stores/agent_core_memory.rb +64 -56
  36. data/lib/little_ghost/session_stores/filesystem.rb +261 -0
  37. data/lib/little_ghost/session_stores/memory.rb +7 -0
  38. data/lib/little_ghost/subagents/manager.rb +42 -42
  39. data/lib/little_ghost/support/executor.rb +14 -2
  40. data/lib/little_ghost/support/loader.rb +2 -2
  41. data/lib/little_ghost/support.rb +15 -3
  42. data/lib/little_ghost/swarm.rb +439 -0
  43. data/lib/little_ghost/tool.rb +88 -20
  44. data/lib/little_ghost/tools/write_todos.rb +6 -1
  45. data/lib/little_ghost/tracing/open_telemetry.rb +14 -3
  46. data/lib/little_ghost/unrestricted_sandbox.rb +1 -1
  47. data/lib/little_ghost/version.rb +1 -1
  48. data/lib/little_ghost/workflow.rb +224 -90
  49. data/lib/little_ghost.rb +36 -25
  50. metadata +17 -5
@@ -21,8 +21,8 @@ module LittleGhost
21
21
  attr_reader :role, :content, :metadata
22
22
 
23
23
  # Creates a frozen message with a supported +role+, normalized +content+, and
24
- # application-defined +metadata+. The content Array and metadata Hash are
25
- # frozen, but nested caller-owned values are retained.
24
+ # application-defined +metadata+. Metadata becomes a frozen DataMap, so
25
+ # String and Symbol keys address the same JSON-compatible value.
26
26
  def initialize(role:, content:, metadata: {})
27
27
  @role = role.to_sym
28
28
  raise ArgumentError, "Unsupported message role: #{role.inspect}" unless ROLES.include?(@role)
@@ -35,7 +35,7 @@ module LittleGhost
35
35
  [content]
36
36
  end
37
37
  @content = blocks.map { |block| Content.normalize(block) }.freeze
38
- @metadata = metadata.freeze
38
+ @metadata = DataMap.new(metadata).freeze
39
39
  freeze
40
40
  end
41
41
 
@@ -64,7 +64,7 @@ module LittleGhost
64
64
 
65
65
  # Produces the JSON-safe message representation.
66
66
  def to_h
67
- {"role" => role.to_s, "content" => content.map(&:to_h), "metadata" => metadata}
67
+ {"role" => role.to_s, "content" => content.map(&:to_h), "metadata" => metadata.to_h}
68
68
  end
69
69
 
70
70
  # Encodes #to_h as JSON, forwarding generator +arguments+.
@@ -30,7 +30,7 @@ module LittleGhost
30
30
  @providers = providers
31
31
  @connections = providers&.connections || {}
32
32
  @profiles = normalize_profiles(profiles || {})
33
- @default_model = default_model&.to_s || "default"
33
+ @default_model = (default_model&.to_s || "default").dup.freeze
34
34
  @provider_registry = provider_adapters.empty? ? provider_registry : ProviderRegistry.new(adapters: provider_adapters)
35
35
  @credential_resolver = credential_resolver
36
36
  configure_default_providers unless providers
@@ -254,7 +254,7 @@ module LittleGhost
254
254
 
255
255
  parent = profile["inherits"] || profile[:inherits]
256
256
  validate_role_name!(parent.to_s) if parent
257
- [role, profile.to_h]
257
+ [role, immutable_selection_value(profile.to_h)]
258
258
  end
259
259
  end
260
260
 
@@ -71,6 +71,8 @@ module LittleGhost
71
71
  #
72
72
  # Every configured root is trusted Ruby code because ERB executes inside the
73
73
  # current process. Keep roots application-controlled and non-user-writable.
74
+ # See the {Prompts as Views guide}[rdoc-ref:docs/guides/prompt_views.md] for the
75
+ # conventional Agent workflow.
74
76
  class PromptResolver
75
77
  DEFAULT_MAX_DEPTH = 20 # :nodoc:
76
78
 
@@ -3,7 +3,7 @@
3
3
  require "securerandom"
4
4
 
5
5
  module LittleGhost
6
- # Observe one top-level agent or workflow execution from start to finish.
6
+ # Observe one top-level assembly execution from start to finish.
7
7
  # A run records its response, outcome, usage, error, and owned resources.
8
8
  #
9
9
  # run = CustomerSupportAgent.ask("Why is transfer 481 pending?")
@@ -12,37 +12,81 @@ module LittleGhost
12
12
  # run.outcome # => "completed"
13
13
  # run.response # => "Transfer 481 is waiting for the receiving bank."
14
14
  #
15
- # The class-level ask helper[rdoc-ref:LittleGhost::Agent.ask] or standalone
16
- # ask method[rdoc-ref:LittleGhost::Agent#ask] consumes the event stream and
17
- # returns the Run. For a live interface, the class-level streaming
18
- # helper[rdoc-ref:LittleGhost::Agent.stream_ask] or standalone streaming
19
- # method[rdoc-ref:LittleGhost::Agent#stream_ask] yields StreamEvent objects
20
- # and returns the same run after enumeration. A run can execute only once.
15
+ # The class-level ask helper[rdoc-ref:LittleGhost::Assembly.ask] and standalone
16
+ # ask method[rdoc-ref:LittleGhost::Assembly#ask] return the Run after work
17
+ # finishes. For a live interface, use the class-level streaming
18
+ # helper[rdoc-ref:LittleGhost::Assembly.stream_ask] or standalone streaming
19
+ # method[rdoc-ref:LittleGhost::Assembly#stream_ask]. The stream yields
20
+ # StreamEvent objects, and enumeration returns the same Run with its final
21
+ # outcome and response. A Run executes only once.
22
+ #
23
+ # stream = CustomerSupportAgent.stream_ask("Where is transfer 481?")
24
+ # run = stream.each do |event|
25
+ # publish(event) if event.type == :text_delta
26
+ # end
27
+ #
28
+ # run.completed? # => true
29
+ # run.response
21
30
  #
22
31
  # Completion, failure, deadline, and cancellation become the +completed+,
23
32
  # +failed+, +partial+, and +cancelled+ outcomes. Ordinary execution failures
24
- # are available through +error+ and the terminal stream event; cleanup, event
25
- # delivery, or instrumentation failures may still raise because the framework
26
- # cannot safely report a clean stop.
33
+ # are available through +error+ and the terminal stream event. Failures while
34
+ # closing resources, delivering events, or reporting instrumentation may
35
+ # still raise because LittleGhost cannot report a reliable ending.
27
36
  #
28
- # The run opens its workspace, sandbox, session, and entrypoint, then closes
29
- # registered resources in reverse order. +register+ extends that lifecycle for
30
- # application resources. Interruption is available only while an agent
31
- # entrypoint is active and unambiguous.
37
+ # Tool validation and ToolError failures return safe Tool results to the model,
38
+ # which may recover and complete the Run. Input, configuration, or resource
39
+ # construction can raise before a Run exists. Once execution begins, terminal
40
+ # events are +run_stop+, +run_error+, +run_partial+, and +run_cancel+.
41
+ #
42
+ # The Run opens its workspace, sandbox, Session, and Assembly entrypoint, then
43
+ # closes registered resources in reverse order. +register+ adds application
44
+ # resources to that lifecycle. Interjection is available only while one Agent
45
+ # entrypoint is active.
32
46
  class Run
33
47
  include Enumerable
34
48
 
35
- # Runtime and declarations used to execute the run; its request, cancellation
36
- # token, resources, terminal outcome, response, result, usage, and error.
37
- attr_reader :runtime, :agent_class, :entrypoint_class, :invocation, :cancellation_token, :result, :operation_id,
38
- :outcome, :response, :error, :session, :usage, :workspace, :sandbox
49
+ # Runtime that built and executes this Run.
50
+ attr_reader :runtime
51
+ # Agent class used for compatibility when the entrypoint is an Agent.
52
+ attr_reader :agent_class
53
+ # Public Agent, Workflow, Swarm, or Graph class selected by the caller.
54
+ attr_reader :entrypoint_class
55
+ # Normalized request carried by this Run.
56
+ attr_reader :invocation
57
+ # Token that cooperatively stops this Run and its children.
58
+ attr_reader :cancellation_token
59
+ # Final RunResult, when the Assembly produced one.
60
+ attr_reader :result
61
+ # Unique identifier for this top-level operation.
62
+ attr_reader :operation_id
63
+ # Terminal String: +completed+, +failed+, +partial+, or +cancelled+.
64
+ attr_reader :outcome
65
+ # Caller-facing final text, or the partial text preserved at a deadline.
66
+ attr_reader :response
67
+ # Exception that caused a failed, partial, or cancelled outcome.
68
+ attr_reader :error
69
+ # Session opened for this invocation, when persistence is configured.
70
+ attr_reader :session
71
+ # Normalized Usage accumulated by the Run.
72
+ attr_reader :usage
73
+ # Request-scoped workspace owned or supplied by the Run.
74
+ attr_reader :workspace
75
+ # Request-scoped sandbox owned or supplied by the Run.
76
+ attr_reader :sandbox
39
77
 
40
78
  # Creates a dormant run for +invocation+.
41
- def initialize(invocation:, agent_class:, runtime:, entrypoint_class: agent_class,
79
+ def initialize(invocation:, runtime:, agent_class: nil, assembly_class: nil, entrypoint_class: nil,
80
+ execution_class: nil,
42
81
  cancellation_token: Support::CancellationToken.new, workspace: nil, sandbox: nil)
82
+ entrypoint_class ||= assembly_class || agent_class
83
+ raise ArgumentError, "entrypoint_class is required" unless entrypoint_class
84
+ execution_class ||= assembly_class || entrypoint_class
85
+
43
86
  @runtime = runtime
44
87
  @agent_class = agent_class
45
88
  @entrypoint_class = entrypoint_class
89
+ @execution_class = execution_class
46
90
  @invocation = invocation
47
91
  @cancellation_token = cancellation_token
48
92
  @workspace = workspace
@@ -55,11 +99,15 @@ module LittleGhost
55
99
  @event_mutex = Mutex.new
56
100
  @subagent_instrumentation_mutex = Mutex.new
57
101
  @subagent_instrumentation = {}
102
+ @assembly_step_instrumentation_mutex = Mutex.new
103
+ @assembly_step_instrumentation = {}
58
104
  @exclusive_tools_mutex = Mutex.new
59
105
  @once_mutex = Mutex.new
60
106
  @once_keys = {}
61
- @interruption_mutex = Mutex.new
62
- @interruption_state = :not_started
107
+ @interjection_mutex = Mutex.new
108
+ @interjection_condition = ConditionVariable.new
109
+ @interjection_state = :not_started
110
+ @active_interjections = 0
63
111
  @entrypoint = nil
64
112
  @usage = Usage.new
65
113
  end
@@ -98,40 +146,57 @@ module LittleGhost
98
146
  # True when cancellation stopped the run without a response.
99
147
  def cancelled? = outcome == "cancelled"
100
148
 
101
- # Adds an interruption to the active entrypoint and waits for its response.
149
+ # Adds an interjection to the active entrypoint and waits for its response.
102
150
  #
103
- # Raises LittleGhost::AgentInterruptError before the entrypoint is ready,
104
- # after it finishes, or when the entrypoint does not support interruptions.
105
- def interrupt_response(
151
+ # Raises LittleGhost::AgentInterjectionError before the entrypoint is ready,
152
+ # after it finishes, or when the entrypoint does not support interjections.
153
+ def interject(
106
154
  message,
107
- interruption_id: nil,
155
+ interjection_id: nil,
108
156
  batch_key: nil,
109
157
  metadata: {},
110
158
  cancellation_token: Support::CancellationToken.new,
111
159
  deadline: nil
112
160
  )
113
- entrypoint = @interruption_mutex.synchronize do
114
- case @interruption_state
161
+ interject_with do
162
+ [
163
+ message,
164
+ {
165
+ interjection_id:,
166
+ batch_key:,
167
+ metadata:,
168
+ cancellation_token:,
169
+ deadline:
170
+ }
171
+ ]
172
+ end
173
+ end
174
+
175
+ def interject_with # :nodoc:
176
+ entrypoint = @interjection_mutex.synchronize do
177
+ case @interjection_state
115
178
  when :not_started, :starting
116
- raise AgentInterruptError, "Run entrypoint is not ready for interruptions"
179
+ raise AgentInterjectionError, "Run entrypoint is not ready for interjections"
117
180
  when :terminal
118
- raise AgentInterruptError, "Run has already finished"
181
+ raise AgentInterjectionError, "Run has already finished"
119
182
  end
120
183
 
184
+ @active_interjections += 1
121
185
  @entrypoint
122
186
  end
123
- unless entrypoint.is_a?(Agent)
124
- raise AgentInterruptError, "Run entrypoint does not support interruptions"
187
+ unless entrypoint.respond_to?(:interject)
188
+ raise AgentInterjectionError, "Run entrypoint does not support interjections"
125
189
  end
126
190
 
127
- entrypoint.interrupt_response(
128
- message,
129
- interruption_id:,
130
- batch_key:,
131
- metadata:,
132
- cancellation_token:,
133
- deadline:
134
- )
191
+ message, options = yield
192
+ entrypoint.interject(message, **options)
193
+ ensure
194
+ if entrypoint
195
+ @interjection_mutex.synchronize do
196
+ @active_interjections -= 1
197
+ @interjection_condition.broadcast
198
+ end
199
+ end
135
200
  end
136
201
 
137
202
  # Creates a RunContext with this run's cancellation token and deadline.
@@ -183,8 +248,8 @@ module LittleGhost
183
248
  end
184
249
  end
185
250
 
186
- def prepare_interruption(payload) # :nodoc:
187
- runtime.prepare_interruption(self, payload)
251
+ def prepare_interjection(payload) # :nodoc:
252
+ runtime.prepare_interjection(self, payload)
188
253
  end
189
254
 
190
255
  # Closes registered resources in reverse order.
@@ -209,6 +274,10 @@ module LittleGhost
209
274
  outcome: cleanup_error ? :error : :cancelled,
210
275
  error_type: cleanup_error&.class&.name
211
276
  )
277
+ finish_remaining_assembly_step_instrumentation(
278
+ outcome: cleanup_error ? :error : :cancelled,
279
+ error_type: cleanup_error&.class&.name
280
+ )
212
281
  rescue => error
213
282
  errors << error
214
283
  end
@@ -229,7 +298,7 @@ module LittleGhost
229
298
  :run,
230
299
  parent: nil,
231
300
  **correlation_attributes,
232
- entrypoint_kind: workflow_run? ? :workflow : :agent,
301
+ entrypoint_kind:,
233
302
  workflow_name: workflow_run? ? entrypoint_name : nil,
234
303
  trace_context: invocation[:parent_trace_context],
235
304
  trace_links: invocation[:trace_links],
@@ -241,17 +310,13 @@ module LittleGhost
241
310
  workspace&.open(run: self)
242
311
  sandbox&.open(run: self)
243
312
  @session = runtime.open_session(self)
244
- agent = if entrypoint_class <= Agent
245
- runtime.build_agent(entrypoint_class, run: self)
246
- else
247
- entrypoint_class.new(run: self, runtime:)
248
- end
249
- @interruption_mutex.synchronize do
313
+ agent = runtime.build_assembly(@execution_class, run: self)
314
+ @interjection_mutex.synchronize do
250
315
  @entrypoint = agent
251
316
  end
252
317
  register(agent)
253
318
  invoke = lambda do
254
- history = session ? session.history(fallback: invocation.history) : invocation.history
319
+ history = session ? runtime.session_history(self, session, fallback: invocation.history) : invocation.history
255
320
  context = session ? session.state.merge(invocation.context) : invocation.context.dup
256
321
  options = {
257
322
  history:,
@@ -267,11 +332,11 @@ module LittleGhost
267
332
  end
268
333
  }
269
334
  if agent.is_a?(Agent)
270
- options[:interrupt_ready] = lambda do
271
- @interruption_mutex.synchronize { @interruption_state = :active }
335
+ options[:interject_ready] = lambda do
336
+ @interjection_mutex.synchronize { @interjection_state = :active }
272
337
  end
273
338
  else
274
- @interruption_mutex.synchronize { @interruption_state = :active }
339
+ @interjection_mutex.synchronize { @interjection_state = :active }
275
340
  end
276
341
 
277
342
  agent.stream(invocation.message, **options).each do |event|
@@ -332,10 +397,11 @@ module LittleGhost
332
397
  }
333
398
  ]
334
399
  ensure
335
- @interruption_mutex.synchronize do
400
+ @interjection_mutex.synchronize do
401
+ @interjection_state = :terminal
402
+ @interjection_condition.wait(@interjection_mutex) while @active_interjections.positive?
336
403
  @last_entrypoint = @entrypoint
337
404
  @entrypoint = nil
338
- @interruption_state = :terminal
339
405
  end
340
406
  resource_cleanup_error = nil
341
407
  begin
@@ -402,25 +468,39 @@ module LittleGhost
402
468
  end
403
469
 
404
470
  def correlation_attributes
405
- {
471
+ attributes = {
406
472
  operation_id:,
407
473
  run_id: invocation.run_id,
408
474
  invocation_id: invocation.invocation_id,
409
475
  session_id: invocation.session_id,
410
- service_name: runtime.service_name,
411
- agent_id: workflow_run? ? nil : entrypoint_name,
412
- workflow_name: workflow_run? ? entrypoint_name : nil
413
- }.compact
476
+ service_name: runtime.service_name
477
+ }
478
+ case entrypoint_kind
479
+ when :agent
480
+ attributes[:agent_id] = entrypoint_name
481
+ when :workflow
482
+ attributes[:workflow_name] = entrypoint_name
483
+ else
484
+ attributes[:assembly_id] = entrypoint_name
485
+ attributes[:assembly_kind] = entrypoint_kind
486
+ end
487
+ attributes
414
488
  end
415
489
 
416
- def workflow_run? = entrypoint_class <= Workflow
490
+ def workflow_run? = defined?(Workflow) && entrypoint_class <= Workflow
491
+
492
+ def entrypoint_kind
493
+ return entrypoint_class.assembly_kind if entrypoint_class.respond_to?(:assembly_kind)
494
+
495
+ workflow_run? ? :workflow : :agent
496
+ end
417
497
 
418
498
  def entrypoint_name
419
499
  return entrypoint_class.name.to_s if workflow_run?
420
500
 
421
- return @entrypoint.entrypoint_name if @entrypoint.is_a?(Agent)
501
+ return @entrypoint.entrypoint_name if @entrypoint&.respond_to?(:entrypoint_name)
422
502
 
423
- entrypoint_class.agent_id
503
+ entrypoint_class.respond_to?(:assembly_id) ? entrypoint_class.assembly_id : entrypoint_class.name.to_s
424
504
  end
425
505
 
426
506
  def instrument_subagent(data)
@@ -485,7 +565,53 @@ module LittleGhost
485
565
  error_class ||= error if error.is_a?(String) && error.match?(/\A[A-Z]\w*(?:::[A-Z]\w*)*\z/)
486
566
  attributes = data.slice(:attempt, :delay, :error_code, :http_status, :partial_text)
487
567
  instrument(:model_retry, attributes.merge(error_class:).compact)
568
+ when :assembly_step_start
569
+ start_assembly_step_instrumentation(data)
570
+ when :assembly_step_stop
571
+ finish_assembly_step_instrumentation(data.fetch(:step_id), data.merge(outcome: :completed))
572
+ when :assembly_step_error
573
+ return unless data[:terminal]
574
+
575
+ finish_assembly_step_instrumentation(data.fetch(:step_id), data.merge(outcome: :error))
576
+ when :assembly_step_retry, :assembly_fork, :assembly_join, :assembly_transition, :assembly_handoff_loop
577
+ instrument(type, data.except(:usage))
578
+ end
579
+ end
580
+
581
+ def start_assembly_step_instrumentation(attributes)
582
+ step_id = attributes.fetch(:step_id)
583
+ handle = Instrumentation.start(
584
+ :assembly_step,
585
+ parent: attributes[:parent_operation_id] || operation_id,
586
+ operation_id: step_id,
587
+ detached: true,
588
+ assembly_id: attributes[:assembly_id],
589
+ assembly_kind: attributes[:assembly_kind],
590
+ participant: attributes[:participant],
591
+ branch_id: attributes[:branch_id]
592
+ )
593
+ @assembly_step_instrumentation_mutex.synchronize { @assembly_step_instrumentation[step_id] = handle }
594
+ end
595
+
596
+ def finish_assembly_step_instrumentation(step_id, attributes)
597
+ handle = @assembly_step_instrumentation_mutex.synchronize do
598
+ @assembly_step_instrumentation.delete(step_id)
599
+ end
600
+ return unless handle
601
+
602
+ usage = attributes[:usage]
603
+ handle.finish(
604
+ outcome: attributes[:outcome],
605
+ error_type: attributes[:error_type],
606
+ **(usage ? usage_attributes(usage) : {})
607
+ )
608
+ end
609
+
610
+ def finish_remaining_assembly_step_instrumentation(outcome:, error_type: nil)
611
+ handles = @assembly_step_instrumentation_mutex.synchronize do
612
+ @assembly_step_instrumentation.values.tap { @assembly_step_instrumentation.clear }
488
613
  end
614
+ handles.reverse_each { |handle| handle.finish(outcome:, error_type:) }
489
615
  end
490
616
 
491
617
  def start_subagent_instrumentation(attributes)
@@ -543,7 +669,7 @@ module LittleGhost
543
669
  raise Error, "run has already started" if @started
544
670
  @started = true
545
671
  end
546
- @interruption_mutex.synchronize { @interruption_state = :starting }
672
+ @interjection_mutex.synchronize { @interjection_state = :starting }
547
673
  end
548
674
 
549
675
  def close_callback(resource)
@@ -9,12 +9,25 @@ module LittleGhost
9
9
  # cancellation and deadlines, checkpoint messages, and accumulate usage.
10
10
  # Access to framework-managed fields is thread-safe.
11
11
  class RunContext
12
- # Shared state, cancellation, deadline, metadata, operation identity, and
13
- # durable conversation identity for the current work.
14
- attr_reader :state, :cancellation_token, :deadline, :metadata,
15
- :agent_operation_id, :conversation_id
16
-
17
- # Creates a context with optional checkpoint and interruption state.
12
+ # Mutable DataMap state supplied to this invocation. A top-level Run starts
13
+ # with restored Session state merged with current Invocation context; child
14
+ # Assemblies may receive copied, mapped, or empty state. Application code must
15
+ # synchronize mutations when parallel Tools share this map, or use exclusive
16
+ # Tools. String and Symbol keys address the same value; persisted snapshots
17
+ # use canonical String keys.
18
+ attr_reader :state
19
+ # Token used to cooperatively stop the current work.
20
+ attr_reader :cancellation_token
21
+ # Wall-clock deadline for the current work, when present.
22
+ attr_reader :deadline
23
+ # Framework metadata attached to this context.
24
+ attr_reader :metadata
25
+ # Active Agent operation identifier, after the context is bound.
26
+ attr_reader :agent_operation_id
27
+ # Durable subagent conversation identifier, when present.
28
+ attr_reader :conversation_id
29
+
30
+ # Creates a context with optional checkpoint and interjection state.
18
31
  def initialize(
19
32
  state: {},
20
33
  cancellation_token: Support::CancellationToken.new,
@@ -22,15 +35,15 @@ module LittleGhost
22
35
  metadata: {},
23
36
  checkpoint: nil,
24
37
  conversation_id: nil,
25
- interruption_metadata: nil,
26
- interruption_ids: []
38
+ interjection_metadata: nil,
39
+ interjection_ids: []
27
40
  )
28
41
  if conversation_id
29
42
  conversation_id = String(conversation_id)
30
43
  raise ArgumentError, "conversation_id cannot be empty" if conversation_id.empty?
31
44
  conversation_id = conversation_id.dup.freeze
32
45
  end
33
- @state = state
46
+ @state = DataMap.new(state)
34
47
  @cancellation_token = cancellation_token
35
48
  @deadline = deadline
36
49
  @metadata = metadata.freeze
@@ -42,9 +55,9 @@ module LittleGhost
42
55
  @structured_result_mutex = Mutex.new
43
56
  @agent_operation_id = nil
44
57
  @agent_operation_id_mutex = Mutex.new
45
- @interruption_mutex = Mutex.new
46
- @interruption_metadata = interruption_metadata&.to_h
47
- @interruption_ids = Array(interruption_ids).map { |id| String(id).dup.freeze }.freeze
58
+ @interjection_mutex = Mutex.new
59
+ @interjection_metadata = interjection_metadata&.to_h
60
+ @interjection_ids = Array(interjection_ids).map { |id| String(id).dup.freeze }.freeze
48
61
  end
49
62
 
50
63
  # Raises LittleGhost::CancelledError or LittleGhost::DeadlineExceededError
@@ -99,20 +112,20 @@ module LittleGhost
99
112
  @structured_result_mutex.synchronize { @structured_result }
100
113
  end
101
114
 
102
- def interruption_metadata # :nodoc:
103
- @interruption_mutex.synchronize { @interruption_metadata }
115
+ def interjection_metadata # :nodoc:
116
+ @interjection_mutex.synchronize { @interjection_metadata }
104
117
  end
105
118
 
106
- def interruption_ids # :nodoc:
107
- @interruption_mutex.synchronize { @interruption_ids }
119
+ def interjection_ids # :nodoc:
120
+ @interjection_mutex.synchronize { @interjection_ids }
108
121
  end
109
122
 
110
- def activate_interruption(metadata:, ids:) # :nodoc:
123
+ def activate_interjection(metadata:, ids:) # :nodoc:
111
124
  value = metadata&.to_h
112
125
  values = Array(ids).map { |id| String(id).dup.freeze }.freeze
113
- @interruption_mutex.synchronize do
114
- @interruption_metadata = value
115
- @interruption_ids = values
126
+ @interjection_mutex.synchronize do
127
+ @interjection_metadata = value
128
+ @interjection_ids = values
116
129
  end
117
130
  end
118
131
 
@@ -4,16 +4,16 @@ module LittleGhost
4
4
  # Associates a validated structured value with its declared schema name.
5
5
  StructuredResult = Data.define(:schema_name, :value) # :nodoc:
6
6
 
7
- # RunResult gives callers one final view of an agent or workflow invocation.
8
- # It includes the response, usage, updated conversation, state, and any
9
- # validated structured value.
7
+ # RunResult gives callers one final view of an assembly invocation.
8
+ # It includes the response, usage, updated conversation, state, coordination
9
+ # steps, and any validated structured value.
10
10
  #
11
11
  # Use #output when the caller should accept either structured or textual
12
12
  # agents. It returns the validated structured value when present and #text
13
13
  # otherwise.
14
- RunResult = Data.define(:message, :stop_reason, :usage, :messages, :state, :structured_result) do # :nodoc:
15
- def initialize(message:, stop_reason:, usage:, messages:, state:, structured_result: nil)
16
- super
14
+ RunResult = Data.define(:message, :stop_reason, :usage, :messages, :state, :structured_result, :steps) do # :nodoc:
15
+ def initialize(message:, stop_reason:, usage:, messages:, state:, structured_result: nil, steps: [])
16
+ super(message:, stop_reason:, usage:, messages:, state:, structured_result:, steps: Array(steps).freeze)
17
17
  end
18
18
 
19
19
  # Reads the final message text, or an empty string when no message exists.
@@ -30,6 +30,9 @@ module LittleGhost
30
30
  def output
31
31
  structured? ? structured_result.value : text
32
32
  end
33
+
34
+ # Returns immutable coordination queries for this result.
35
+ def trajectory = Assembly::Trajectory.new(steps)
33
36
  end
34
37
 
35
38
  # Associates a validated structured value with its declared schema name.
@@ -56,9 +59,9 @@ module LittleGhost
56
59
  # The locally validated application value.
57
60
  end
58
61
 
59
- # RunResult gives callers one final view of an agent or workflow invocation.
60
- # It includes the response, usage, updated conversation, state, and any
61
- # validated structured value.
62
+ # RunResult gives callers one final view of an Assembly invocation.
63
+ # It includes the response, usage, updated conversation, state, coordination
64
+ # steps, and any validated structured value.
62
65
  #
63
66
  # Use #output when the caller should accept either structured or textual
64
67
  # agents. It returns the validated structured value when present and #text
@@ -68,9 +71,9 @@ module LittleGhost
68
71
  # :singleton-method: new
69
72
  # :call-seq:
70
73
  # new(message:, stop_reason:, usage:, messages:, state:,
71
- # structured_result: nil) -> RunResult
74
+ # structured_result: nil, steps: []) -> RunResult
72
75
  #
73
- # Creates the terminal value for one agent or workflow invocation.
76
+ # Creates the terminal value for one Assembly invocation.
74
77
 
75
78
  ##
76
79
  # :attr_reader: message
@@ -96,6 +99,10 @@ module LittleGhost
96
99
  # :attr_reader: structured_result
97
100
  # The validated StructuredResult, or +nil+ for a textual result.
98
101
 
102
+ ##
103
+ # :attr_reader: steps
104
+ # Immutable Assembly::Step records for composite invocations.
105
+
99
106
  ##
100
107
  # :method: text
101
108
  # Reads the final message text, or an empty string when no message exists.
@@ -107,5 +114,9 @@ module LittleGhost
107
114
  ##
108
115
  # :method: output
109
116
  # Uses the structured value when present and otherwise #text.
117
+
118
+ ##
119
+ # :method: trajectory
120
+ # Returns an Assembly::Trajectory for querying immutable coordination steps.
110
121
  end
111
122
  end
@@ -2,8 +2,8 @@
2
2
 
3
3
  module LittleGhost
4
4
  class Runtime
5
- # Hooks let applications prepare runs, transform interruptions, and map
6
- # errors to caller-safe messages.
5
+ # Hooks let applications prepare runs, select session history, transform
6
+ # interjections, and map errors to caller-safe messages.
7
7
  #
8
8
  # Hooks are instantiated once per Runtime in configuration order. Override
9
9
  # only the methods needed and return the supplied value when leaving it
@@ -20,8 +20,13 @@ module LittleGhost
20
20
  # lifecycle and close in reverse order.
21
21
  def prepare_run(run) = run
22
22
 
23
- # Transforms an interruption payload before it reaches the agent.
24
- def prepare_interruption(_run, payload) = payload
23
+ # Transforms an interjection payload before it reaches the agent.
24
+ def prepare_interjection(_run, payload) = payload
25
+
26
+ # Returns the history to use for this run, or nil to defer to later hooks
27
+ # and the session default. +stored+ is empty when the session is new;
28
+ # +fallback+ contains messages supplied by the invocation.
29
+ def session_history(_run, stored:, fallback:) = nil
25
30
 
26
31
  # Returns a caller-safe error message, or nil to defer to later hooks and
27
32
  # the runtime default. Avoid exposing secrets or internal exception text.