little_ghost 0.3.0 → 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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +68 -84
  3. data/docs/guides/assemblies.md +286 -0
  4. data/docs/guides/core_concepts.md +126 -231
  5. data/docs/guides/getting_started.md +114 -87
  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 +1 -1
  10. data/lib/little_ghost/agent.rb +167 -172
  11. data/lib/little_ghost/{agent_interruptions.rb → agent_interjections.rb} +12 -12
  12. data/lib/little_ghost/assembly.rb +55 -21
  13. data/lib/little_ghost/assembly_builder.rb +40 -2
  14. data/lib/little_ghost/assembly_execution.rb +87 -4
  15. data/lib/little_ghost/configuration.rb +263 -39
  16. data/lib/little_ghost/content.rb +5 -5
  17. data/lib/little_ghost/data_map.rb +209 -0
  18. data/lib/little_ghost/errors.rb +2 -2
  19. data/lib/little_ghost/execution.rb +32 -32
  20. data/lib/little_ghost/graph.rb +22 -3
  21. data/lib/little_ghost/message.rb +4 -4
  22. data/lib/little_ghost/model_resolver.rb +2 -2
  23. data/lib/little_ghost/prompt_resolver.rb +2 -0
  24. data/lib/little_ghost/run.rb +87 -49
  25. data/lib/little_ghost/run_context.rb +33 -20
  26. data/lib/little_ghost/runtime/hook.rb +3 -3
  27. data/lib/little_ghost/runtime.rb +71 -31
  28. data/lib/little_ghost/session.rb +12 -23
  29. data/lib/little_ghost/session_store.rb +9 -5
  30. data/lib/little_ghost/session_stores/agent_core_memory.rb +64 -56
  31. data/lib/little_ghost/session_stores/filesystem.rb +261 -0
  32. data/lib/little_ghost/session_stores/memory.rb +7 -0
  33. data/lib/little_ghost/subagents/manager.rb +42 -42
  34. data/lib/little_ghost/swarm.rb +13 -5
  35. data/lib/little_ghost/tool.rb +56 -14
  36. data/lib/little_ghost/tools/write_todos.rb +6 -1
  37. data/lib/little_ghost/tracing/open_telemetry.rb +1 -1
  38. data/lib/little_ghost/version.rb +1 -1
  39. data/lib/little_ghost/workflow.rb +30 -21
  40. data/lib/little_ghost.rb +29 -25
  41. metadata +7 -2
@@ -12,30 +12,68 @@ 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::Assembly.ask] or standalone
16
- # ask method[rdoc-ref:LittleGhost::Assembly#ask] consumes the event stream and
17
- # returns the Run. For a live interface, the class-level streaming
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
18
  # helper[rdoc-ref:LittleGhost::Assembly.stream_ask] or standalone streaming
19
- # method[rdoc-ref:LittleGhost::Assembly#stream_ask] yields StreamEvent objects
20
- # and returns the same run after enumeration. A run can execute only once.
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.
36
+ #
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+.
27
41
  #
28
- # The run opens its workspace, sandbox, session, and assembly 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.
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
79
  def initialize(invocation:, runtime:, agent_class: nil, assembly_class: nil, entrypoint_class: nil,
@@ -66,10 +104,10 @@ module LittleGhost
66
104
  @exclusive_tools_mutex = Mutex.new
67
105
  @once_mutex = Mutex.new
68
106
  @once_keys = {}
69
- @interruption_mutex = Mutex.new
70
- @interruption_condition = ConditionVariable.new
71
- @interruption_state = :not_started
72
- @active_interruptions = 0
107
+ @interjection_mutex = Mutex.new
108
+ @interjection_condition = ConditionVariable.new
109
+ @interjection_state = :not_started
110
+ @active_interjections = 0
73
111
  @entrypoint = nil
74
112
  @usage = Usage.new
75
113
  end
@@ -108,23 +146,23 @@ module LittleGhost
108
146
  # True when cancellation stopped the run without a response.
109
147
  def cancelled? = outcome == "cancelled"
110
148
 
111
- # 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.
112
150
  #
113
- # Raises LittleGhost::AgentInterruptError before the entrypoint is ready,
114
- # after it finishes, or when the entrypoint does not support interruptions.
115
- 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(
116
154
  message,
117
- interruption_id: nil,
155
+ interjection_id: nil,
118
156
  batch_key: nil,
119
157
  metadata: {},
120
158
  cancellation_token: Support::CancellationToken.new,
121
159
  deadline: nil
122
160
  )
123
- interrupt_response_with do
161
+ interject_with do
124
162
  [
125
163
  message,
126
164
  {
127
- interruption_id:,
165
+ interjection_id:,
128
166
  batch_key:,
129
167
  metadata:,
130
168
  cancellation_token:,
@@ -134,29 +172,29 @@ module LittleGhost
134
172
  end
135
173
  end
136
174
 
137
- def interrupt_response_with # :nodoc:
138
- entrypoint = @interruption_mutex.synchronize do
139
- case @interruption_state
175
+ def interject_with # :nodoc:
176
+ entrypoint = @interjection_mutex.synchronize do
177
+ case @interjection_state
140
178
  when :not_started, :starting
141
- raise AgentInterruptError, "Run entrypoint is not ready for interruptions"
179
+ raise AgentInterjectionError, "Run entrypoint is not ready for interjections"
142
180
  when :terminal
143
- raise AgentInterruptError, "Run has already finished"
181
+ raise AgentInterjectionError, "Run has already finished"
144
182
  end
145
183
 
146
- @active_interruptions += 1
184
+ @active_interjections += 1
147
185
  @entrypoint
148
186
  end
149
- unless entrypoint.respond_to?(:interrupt_response)
150
- raise AgentInterruptError, "Run entrypoint does not support interruptions"
187
+ unless entrypoint.respond_to?(:interject)
188
+ raise AgentInterjectionError, "Run entrypoint does not support interjections"
151
189
  end
152
190
 
153
191
  message, options = yield
154
- entrypoint.interrupt_response(message, **options)
192
+ entrypoint.interject(message, **options)
155
193
  ensure
156
194
  if entrypoint
157
- @interruption_mutex.synchronize do
158
- @active_interruptions -= 1
159
- @interruption_condition.broadcast
195
+ @interjection_mutex.synchronize do
196
+ @active_interjections -= 1
197
+ @interjection_condition.broadcast
160
198
  end
161
199
  end
162
200
  end
@@ -210,8 +248,8 @@ module LittleGhost
210
248
  end
211
249
  end
212
250
 
213
- def prepare_interruption(payload) # :nodoc:
214
- runtime.prepare_interruption(self, payload)
251
+ def prepare_interjection(payload) # :nodoc:
252
+ runtime.prepare_interjection(self, payload)
215
253
  end
216
254
 
217
255
  # Closes registered resources in reverse order.
@@ -273,7 +311,7 @@ module LittleGhost
273
311
  sandbox&.open(run: self)
274
312
  @session = runtime.open_session(self)
275
313
  agent = runtime.build_assembly(@execution_class, run: self)
276
- @interruption_mutex.synchronize do
314
+ @interjection_mutex.synchronize do
277
315
  @entrypoint = agent
278
316
  end
279
317
  register(agent)
@@ -294,11 +332,11 @@ module LittleGhost
294
332
  end
295
333
  }
296
334
  if agent.is_a?(Agent)
297
- options[:interrupt_ready] = lambda do
298
- @interruption_mutex.synchronize { @interruption_state = :active }
335
+ options[:interject_ready] = lambda do
336
+ @interjection_mutex.synchronize { @interjection_state = :active }
299
337
  end
300
338
  else
301
- @interruption_mutex.synchronize { @interruption_state = :active }
339
+ @interjection_mutex.synchronize { @interjection_state = :active }
302
340
  end
303
341
 
304
342
  agent.stream(invocation.message, **options).each do |event|
@@ -359,9 +397,9 @@ module LittleGhost
359
397
  }
360
398
  ]
361
399
  ensure
362
- @interruption_mutex.synchronize do
363
- @interruption_state = :terminal
364
- @interruption_condition.wait(@interruption_mutex) while @active_interruptions.positive?
400
+ @interjection_mutex.synchronize do
401
+ @interjection_state = :terminal
402
+ @interjection_condition.wait(@interjection_mutex) while @active_interjections.positive?
365
403
  @last_entrypoint = @entrypoint
366
404
  @entrypoint = nil
367
405
  end
@@ -631,7 +669,7 @@ module LittleGhost
631
669
  raise Error, "run has already started" if @started
632
670
  @started = true
633
671
  end
634
- @interruption_mutex.synchronize { @interruption_state = :starting }
672
+ @interjection_mutex.synchronize { @interjection_state = :starting }
635
673
  end
636
674
 
637
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
 
@@ -3,7 +3,7 @@
3
3
  module LittleGhost
4
4
  class Runtime
5
5
  # Hooks let applications prepare runs, select session history, transform
6
- # interruptions, and map errors to caller-safe messages.
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,8 @@ 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
25
 
26
26
  # Returns the history to use for this run, or nil to defer to later hooks
27
27
  # and the session default. +stored+ is empty when the session is new;
@@ -4,45 +4,86 @@ require "json"
4
4
  require_relative "configuration"
5
5
 
6
6
  module LittleGhost
7
- # Prepare the shared services that assemblies use across many runs.
8
- # A runtime owns model resolution, loading, persistence, lookup paths, hooks,
9
- # and resource factories for one Ruby setup.
7
+ # Owns the shared services that assemblies reuse across many Runs.
8
+ #
9
+ # Most applications do not construct this class. Configure LittleGhost once
10
+ # and call a named Agent or Assembly; the first standalone call lazily builds
11
+ # +LittleGhost.runtime+, and later calls reuse it automatically. Each call
12
+ # still receives a fresh Run, bound participants, Tools, workspace, and
13
+ # sandbox.
14
+ #
15
+ # Construct Runtime directly when one process intentionally hosts an isolated
16
+ # LittleGhost setup:
10
17
  #
11
18
  # configuration = LittleGhost::Configuration.new(
12
19
  # root: Dir.pwd,
13
20
  # providers: {
14
- # openai: {adapter: :openai, api_key: ENV.fetch("OPENAI_API_KEY")}
21
+ # openrouter: {adapter: :openrouter, api_key: ENV.fetch("OPENROUTER_API_KEY")}
15
22
  # },
16
- # models: {customer_support: {target: "openai:gpt-5.6-luna"}},
17
- # default_model: "customer_support",
23
+ # models: {customer_support: {target: "openrouter:openai/gpt-5.6-luna"}},
24
+ # default_model: :customer_support,
18
25
  # service_name: "support-api"
19
26
  # )
20
27
  # runtime = LittleGhost::Runtime.new(configuration: configuration)
21
28
  #
22
- # runtime.service_name # => "support-api"
23
- # runtime.root == Pathname.new(File.realpath(Dir.pwd)) # => true
29
+ # CustomerSupportAgent.new(runtime: runtime)
30
+ # .ask("Where is order 481?")
31
+ # .response
32
+ #
33
+ # Explicit construction snapshots the supplied Configuration but does not
34
+ # replace LittleGhost's shared default Runtime.
35
+ #
36
+ # A Runtime may build independent Runs concurrently. Each Run gets its own
37
+ # bound participants and Tools. By default, Runtime also creates a workspace
38
+ # and sandbox owned by that Run. Existing workspace or sandbox instances
39
+ # passed by the application remain caller-owned. Other supplied objects may
40
+ # receive calls from several threads, so custom stores, resolvers, hooks,
41
+ # subscribers, providers, and resource factories must be thread-safe. One
42
+ # SessionStore instance serializes calls sharing a Session; multi-process
43
+ # deployments need coordination provided by their store.
44
+ #
45
+ # == Advanced construction and ownership
24
46
  #
25
- # Without explicit +settings+, construction canonicalizes the root, loads
26
- # +config/little_ghost.rb+ once through the Configuration, snapshots settings,
27
- # configures instrumentation, eager-loads application constants, and builds the
28
- # selected model resolver and session store. Supplying +settings+ is the
29
- # lower-level path used to create a sibling runtime from an existing snapshot.
47
+ # Normal construction reads the application's configured definitions and
48
+ # builds shared model resolution, persistence, hooks, and resource factories.
49
+ # The +settings+ form and #build are lower-level extension points for deriving
50
+ # another Runtime from an existing configuration snapshot.
30
51
  #
31
- # Reuse a runtime across runs. +build_run+ creates any missing workspace and
32
- # sandbox, transfers ownership only after both are built, and closes partial
33
- # resources if construction fails. +build+ creates a sibling with explicit
34
- # overrides and reuses the loader only when the application root is unchanged.
52
+ # #build_run creates a workspace and sandbox when needed. Once the Run owns
53
+ # them, it closes them; if construction stops halfway through, Runtime closes
54
+ # the partial resources. Startup failures are reported to instrumentation and
55
+ # then raised. Session actor resolution must use authenticated application
56
+ # identity. The default UnrestrictedSandbox uses host permissions and is not a
57
+ # security boundary for untrusted work.
35
58
  #
36
- # Startup emits structured lifecycle instrumentation; a failed phase emits a
37
- # failure event, flushes instrumentation, and re-raises the original exception.
38
- # Session actor resolution must use trusted authenticated identity for tenant
39
- # isolation. The default UnrestrictedSandbox is convenient application plumbing,
40
- # not a security boundary for untrusted work.
59
+ # Runtime has no shutdown operation. Runs close resources created for their
60
+ # request. The application shuts down shared services and process-wide
61
+ # Instrumentation subscribers with the rest of the process.
41
62
  class Runtime
42
- # The snapshotted setup and materialized services used by new runs.
43
- attr_reader :configuration, :settings, :root, :loader, :prompt_paths, :skill_paths,
44
- :skill_resource_root, :model_resolver, :session_store, :workspace_class, :sandbox_class,
45
- :runtime_hooks
63
+ # Configuration object used to construct this Runtime.
64
+ attr_reader :configuration
65
+ # Settings snapshot used by new Runs.
66
+ attr_reader :settings
67
+ # Canonical application root.
68
+ attr_reader :root
69
+ # Loader used for conventional application definitions.
70
+ attr_reader :loader
71
+ # Ordered directories searched for prompt templates.
72
+ attr_reader :prompt_paths
73
+ # Ordered directories searched for skill definitions.
74
+ attr_reader :skill_paths
75
+ # Root used for skill-owned resources, when configured.
76
+ attr_reader :skill_resource_root
77
+ # Resolver that turns model roles and targets into executable Models.
78
+ attr_reader :model_resolver
79
+ # Shared store used to open per-Run Sessions.
80
+ attr_reader :session_store
81
+ # Configured Workspace implementation, or +nil+ for the default.
82
+ attr_reader :workspace_class
83
+ # Configured Sandbox implementation, or +nil+ for the default.
84
+ attr_reader :sandbox_class
85
+ # Runtime hooks called around request and session preparation.
86
+ attr_reader :runtime_hooks
46
87
 
47
88
  # Starts a runtime from +configuration+ or an existing settings snapshot.
48
89
  def initialize(configuration:, settings: nil)
@@ -129,8 +170,7 @@ module LittleGhost
129
170
  payload.is_a?(@invocation_class) ? payload : @invocation_class.new(payload)
130
171
  end
131
172
 
132
- # Creates a Run and transfers ownership of newly created workspace and
133
- # sandbox resources to it.
173
+ # Creates a Run that owns any workspace and sandbox built for the request.
134
174
  def build_run(
135
175
  payload,
136
176
  agent_class: nil,
@@ -230,7 +270,7 @@ module LittleGhost
230
270
  assembly_class_or_name.new(run:, runtime: self)
231
271
  end
232
272
 
233
- # The low-cardinality service name attached to runtime telemetry.
273
+ # The stable service name attached to runtime telemetry.
234
274
  def service_name
235
275
  @settings&.[](:service_name) || default_service_name
236
276
  end
@@ -264,9 +304,9 @@ module LittleGhost
264
304
  run
265
305
  end
266
306
 
267
- def prepare_interruption(run, payload) # :nodoc:
307
+ def prepare_interjection(run, payload) # :nodoc:
268
308
  runtime_hooks.reduce(payload) do |prepared, hook|
269
- hook.prepare_interruption(run, prepared)
309
+ hook.prepare_interjection(run, prepared)
270
310
  end
271
311
  end
272
312
 
@@ -21,7 +21,7 @@ module LittleGhost
21
21
  # store: session.store
22
22
  # )
23
23
  # reopened.history.last.text # => "Hello"
24
- # reopened.state # => {language: "en"}
24
+ # reopened.state[:language] # => "en"
25
25
  #
26
26
  # === Persistence and trust
27
27
  #
@@ -43,7 +43,7 @@ module LittleGhost
43
43
  @actor_id = actor_id&.to_s
44
44
  @store = store
45
45
  @operation_id = operation_id
46
- @metadata = metadata.to_h.freeze
46
+ @metadata = DataMap.new(metadata).freeze
47
47
  @loaded = false
48
48
  end
49
49
 
@@ -63,16 +63,18 @@ module LittleGhost
63
63
  load&.fetch(:messages) || fallback
64
64
  end
65
65
 
66
- # Exposes a mutable copy of the persisted application state.
66
+ # Exposes a mutable DataMap copy of the persisted application state. String
67
+ # and Symbol keys address the same value; persisted snapshots use Strings.
67
68
  def state
68
69
  snapshot = load
69
- snapshot ? mutable_copy(snapshot.fetch(:state)) : {}
70
+ DataMap.new(snapshot ? snapshot.fetch(:state) : {})
70
71
  end
71
72
 
72
73
  # Uses persisted metadata when present and otherwise keeps the metadata from
73
- # construction.
74
+ # construction. The returned DataMap is frozen.
74
75
  def metadata
75
- load&.fetch(:metadata) || @metadata
76
+ loaded = load
77
+ loaded ? DataMap.new(loaded.fetch(:metadata)).freeze : @metadata
76
78
  end
77
79
 
78
80
  # Atomically appends +messages+ when the store still has the expected
@@ -165,8 +167,8 @@ module LittleGhost
165
167
  def build_snapshot(messages:, state:, metadata:)
166
168
  {
167
169
  messages: persistable_messages(messages),
168
- state: Support.deep_dup(state.to_h),
169
- metadata: Support.deep_dup(metadata.to_h)
170
+ state: DataMap.new(state).to_h,
171
+ metadata: DataMap.new(metadata).to_h
170
172
  }.freeze
171
173
  end
172
174
 
@@ -193,8 +195,8 @@ module LittleGhost
193
195
 
194
196
  {
195
197
  messages: persistable_messages(Array(value.fetch(:messages))),
196
- state: Support.deep_dup(value.fetch(:state, {}).to_h),
197
- metadata: Support.deep_dup(value.fetch(:metadata, {}).to_h)
198
+ state: DataMap.new(value.fetch(:state, {})).to_h,
199
+ metadata: DataMap.new(value.fetch(:metadata, {})).to_h
198
200
  }.freeze
199
201
  rescue KeyError, NoMethodError, TypeError => error
200
202
  raise ProtocolError, "Session store returned an invalid value: #{error.class}"
@@ -212,18 +214,5 @@ module LittleGhost
212
214
  sanitized
213
215
  end.freeze
214
216
  end
215
-
216
- def mutable_copy(value)
217
- case value
218
- when Hash
219
- value.to_h { |key, child| [mutable_copy(key), mutable_copy(child)] }
220
- when Array
221
- value.map { |child| mutable_copy(child) }
222
- when String
223
- value.dup
224
- else
225
- value
226
- end
227
- end
228
217
  end
229
218
  end
@@ -21,9 +21,11 @@ module LittleGhost
21
21
  # end
22
22
  # end
23
23
  #
24
- # A snapshot contains +:messages+, +:state+, and +:metadata+. Implementations
25
- # provide #load, #append, and #replace; #append must check +expected_count+
26
- # atomically so two writers cannot silently lose a turn.
24
+ # A snapshot contains +:messages+, +:state+, and +:metadata+. State and
25
+ # metadata cross this boundary as deeply string-keyed JSON mappings. Sessions
26
+ # expose the same data through DataMap, which accepts String or Symbol keys.
27
+ # Implementations provide #load, #append, and #replace; #append must check
28
+ # +expected_count+ atomically so two writers cannot silently lose a turn.
27
29
  #
28
30
  # Actor identity always comes from the caller. A store must not infer it from
29
31
  # ambient process state.
@@ -39,14 +41,16 @@ module LittleGhost
39
41
  raise AbstractMethodError, "#{self.class} must implement #load"
40
42
  end
41
43
 
42
- # Atomically appends sanitized messages and stores state and metadata.
44
+ # Atomically appends sanitized messages and stores canonical JSON state and
45
+ # metadata.
43
46
  # Implementations raise ProtocolError if the persisted message count differs
44
47
  # from +expected_count+.
45
48
  def append(_id, messages:, state:, metadata:, expected_count:, actor_id: nil)
46
49
  raise AbstractMethodError, "#{self.class} must implement #append"
47
50
  end
48
51
 
49
- # Replaces the complete snapshot for +id+ atomically.
52
+ # Replaces the complete snapshot for +id+ atomically with canonical JSON
53
+ # state and metadata.
50
54
  def replace(_id, messages:, state:, metadata:, actor_id: nil)
51
55
  raise AbstractMethodError, "#{self.class} must implement #replace"
52
56
  end