little_ghost 0.5.0 → 0.6.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/docs/guides/code_mode.md +4 -3
  3. data/docs/guides/integrations.md +4 -4
  4. data/docs/guides/production.md +114 -7
  5. data/docs/guides/tools.md +26 -0
  6. data/lib/little_ghost/agent.rb +5 -5
  7. data/lib/little_ghost/assembly.rb +9 -11
  8. data/lib/little_ghost/assembly_execution.rb +8 -17
  9. data/lib/little_ghost/code_mode/broker.rb +4 -0
  10. data/lib/little_ghost/code_mode/javascript/session.rb +23 -24
  11. data/lib/little_ghost/code_mode/ruby/session.rb +18 -18
  12. data/lib/little_ghost/configuration.rb +101 -9
  13. data/lib/little_ghost/execution.rb +31 -25
  14. data/lib/little_ghost/execution_state.rb +3 -3
  15. data/lib/little_ghost/graph.rb +6 -3
  16. data/lib/little_ghost/instrumentation.rb +2 -1
  17. data/lib/little_ghost/mcp/toolset.rb +1 -1
  18. data/lib/little_ghost/network/certificate_authority.rb +6 -0
  19. data/lib/little_ghost/network/envoy_gateway.rb +11 -2
  20. data/lib/little_ghost/run.rb +9 -15
  21. data/lib/little_ghost/run_context.rb +2 -1
  22. data/lib/little_ghost/runtime.rb +12 -3
  23. data/lib/little_ghost/sandbox/process_runner.rb +1 -1
  24. data/lib/little_ghost/sandboxes/unrestricted.rb +1 -2
  25. data/lib/little_ghost/session_stores/filesystem.rb +29 -13
  26. data/lib/little_ghost/subagents/manager.rb +170 -70
  27. data/lib/little_ghost/support/cancellation_token.rb +2 -2
  28. data/lib/little_ghost/support/executor.rb +84 -15
  29. data/lib/little_ghost/support/http_client.rb +9 -2
  30. data/lib/little_ghost/support/interruptible_stream.rb +4 -2
  31. data/lib/little_ghost/support/pooled_thread_runner.rb +126 -0
  32. data/lib/little_ghost/support/serialized_dispatcher.rb +84 -0
  33. data/lib/little_ghost/support/task.rb +126 -0
  34. data/lib/little_ghost/support/task_runner.rb +74 -0
  35. data/lib/little_ghost/support.rb +4 -0
  36. data/lib/little_ghost/version.rb +1 -1
  37. data/lib/little_ghost/workflow.rb +3 -3
  38. data/lib/little_ghost.rb +17 -0
  39. metadata +19 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c6ec454a6085af7b0e2519eb0137e2f70115f0c00197d9f1914ac336c9fe4810
4
- data.tar.gz: bde8feed25fc76a6e0290ec8462b12eb5b2df90c368214850caaa705a3da75d2
3
+ metadata.gz: f7e4df5249569e4b93c22c44929064ad12632ca6119667429004ffc3bc4b7f7a
4
+ data.tar.gz: 6ad9b9d7252c1e0749f8d4503ba820259257fb4b0e46bc638ff3bd426ed191d9
5
5
  SHA512:
6
- metadata.gz: 0c7081dc35068d5e3e3e0d917e4f024657cbecc117a0952b4ccfa329a9a4be2de77a96c87d9d7cf1eb3f9092219ed108d07422fa501e427542fdfb400aaf3e4a
7
- data.tar.gz: 939d5fac29d8a1607225185f09784e2fe014d7ef20c20ba856ebda0b30c8e6634a79a9ff94725287f924576caecb2080204dec5fa7b36af3b801d02c3d9de93c
6
+ metadata.gz: 0a1e7fdc5cfe228456703b0d8b383f67461f5a465f962f010cacbee5ef55a90881f7e7135c5107745f1442a0803ddbe865b4d50912485e738b21566282404c34
7
+ data.tar.gz: db5f637104b9c42a8d470b80079b29a877fddf8a41c1a5c110d8a4b926c136b36933ffe234870a0ab1a53f98083d5f856f2fd1c44ea967ae9852f5d7abebc878
@@ -260,10 +260,11 @@ contains model-written code. An engine may request named runtime paths for its
260
260
  interpreter libraries. Those paths are visible to the child process, but they
261
261
  never become filesystem grants available through Tools.
262
262
 
263
- The session owns every Workspace, Sandbox, child process, thread, and
263
+ The session owns every Workspace, Sandbox, child process, background task, and
264
264
  communication channel it creates. It closes those resources in reverse order.
265
- LittleGhost may use one registered engine instance for concurrent Agent Runs,
266
- so the engine must keep each program's mutable state inside its session.
265
+ One registered engine can open several sessions concurrently on threads or
266
+ fibers. Keep each program's mutable state inside the returned session, and
267
+ synchronize any state the engine shares across sessions.
267
268
 
268
269
  A sandboxed engine must use a backend that owns the complete child process tree
269
270
  or can prevent child processes. An explicitly unrestricted backend may run an
@@ -170,8 +170,8 @@ events.each { |event| websocket.write(JSON.generate(event)) }
170
170
 
171
171
  The adapter translates text, reasoning, Tool activity, usage, retries, trace
172
172
  context, subagent activity, and terminal outcomes. It is stateless between
173
- calls. Your application still owns the connection, thread storage,
174
- backpressure, and disconnect behavior.
173
+ calls. Your application still owns the connection, backpressure, disconnect
174
+ behavior, and any request state its callbacks need.
175
175
 
176
176
  LittleGhost also emits namespaced custom events. Consumers should preserve or
177
177
  deliberately ignore event types they don't recognize. See the [AG-UI event
@@ -182,8 +182,8 @@ client.
182
182
  > results, errors, and participant activity. Check that the connected user may
183
183
  > see the complete Run, then filter fields before sending or storing events.
184
184
 
185
- Enumeration drives the source stream on the current thread. When a client
186
- disconnects, stop enumerating and apply the cancellation behavior your
185
+ Calling `each` drives the source stream on the caller's fiber or thread. When a
186
+ client disconnects, stop enumerating and apply the cancellation behavior your
187
187
  application needs. Closing the socket can't undo Tool work that already ran.
188
188
 
189
189
  ## Trace Runs with OpenTelemetry
@@ -75,6 +75,103 @@ The controller supplies identity and account access from authenticated applicati
75
75
 
76
76
  Configure LittleGhost before the first Agent or Assembly call. Once application services start successfully, the configuration is locked so every request sees one stable setup.
77
77
 
78
+ ## Use an existing fiber scheduler
79
+
80
+ If your application already runs inside a Ruby Fiber scheduler, LittleGhost
81
+ uses it for parallel Tool calls, Workflow and Graph branches, subagent turns,
82
+ background Executions, and nested code-mode Tool calls. LittleGhost does not
83
+ install or run a scheduler. Your scheduler must support `Fiber.schedule`. The
84
+ optional `async` gem used below does.
85
+
86
+ For example, an application using the optional `async` gem can let several
87
+ requests make progress on one thread while each request waits for network I/O.
88
+ Add `gem "async"` to the application's bundle, then start the calls inside an
89
+ Async task:
90
+
91
+ ```ruby
92
+ require "async"
93
+
94
+ questions = [
95
+ "How long do refunds take?",
96
+ "Can I update my delivery address?"
97
+ ]
98
+
99
+ answers = Async do |task|
100
+ questions.map do |question|
101
+ task.async { CustomerSupportAgent.ask(question).response }
102
+ end.map(&:wait)
103
+ end.wait
104
+ ```
105
+
106
+ The default `:auto` setting uses fibers when a call begins inside a scheduled
107
+ fiber and worker threads everywhere else. Set `concurrency_backend` to `:fiber`
108
+ when calling outside a scheduled fiber should be an application error.
109
+ LittleGhost then raises `LittleGhost::ConfigurationError` instead of quietly
110
+ starting a thread.
111
+
112
+ ### Keep one blocking call from pausing other fibers
113
+
114
+ Fiber scheduling helps while work waits for I/O; it does not make CPU-heavy
115
+ Ruby code run in parallel. Whether an I/O call lets other fibers run depends on
116
+ the Ruby version, scheduler, and library. Call libraries normally at first. If
117
+ documentation or measurement shows that one call pauses the other fibers—and
118
+ the work can continue on another Ruby thread—wrap that call:
119
+
120
+ ```ruby
121
+ article = LittleGhost.offload_blocking do
122
+ HelpCenterSearch.lookup(question)
123
+ end
124
+ ```
125
+
126
+ Outside a scheduled fiber, `offload_blocking` runs the block inline. Inside one,
127
+ it uses LittleGhost's shared pool of reusable threads so the other fibers can
128
+ continue. If the caller is cancelled after the pool accepts the block,
129
+ LittleGhost waits for the block to finish before reporting the cancellation.
130
+ The helper does not add a timeout or cancellation mechanism to the underlying
131
+ call, so use the library's controls when available.
132
+
133
+ If most of your Tool or extension code prevents other fibers from running, use
134
+ worker threads for all LittleGhost concurrency instead:
135
+
136
+ ```ruby
137
+ LittleGhost.configure do |config|
138
+ config.concurrency_backend = :thread
139
+ end
140
+ ```
141
+
142
+ LittleGhost also keeps a few jobs on threads so it can finish or clean them up
143
+ reliably. Provider and subprocess streams may use dedicated threads. Inside a
144
+ scheduled fiber, certificate generation and Filesystem SessionStore
145
+ transactions use the same shared pool as `offload_blocking`. A Run may
146
+ therefore still create or use threads when its concurrency backend is `:fiber`.
147
+
148
+ The blocking pool allows two operations at a time by default. Applications
149
+ that observe calls waiting for a pool worker can increase its process-wide
150
+ capacity during startup, before any call can start the pool:
151
+
152
+ ```ruby
153
+ LittleGhost.configure do |config|
154
+ config.blocking_pool_capacity 4
155
+ end
156
+ ```
157
+
158
+ Increasing this value permits more operating-system threads. It does not
159
+ change Tool, Workflow, Graph, or subagent concurrency. The value is
160
+ process-wide; every `Configuration` reads and writes the same setting.
161
+
162
+ ### Protect state shared by concurrent calls
163
+
164
+ LittleGhost may call a shared Tool, provider, SessionStore, hook, or callback
165
+ from different threads. Fibers can also take turns entering the same object on
166
+ one thread. Protect shared mutable state, keep lock scope narrow, and do not
167
+ call application callbacks while holding a lock.
168
+
169
+ Pass request-specific values through the Invocation context or another explicit
170
+ argument. Do not use `thread_variable_set` for request state because every
171
+ fiber on the thread shares those values. `Thread.current[:key]` is fiber-local,
172
+ but LittleGhost does not copy application-defined entries into each worker
173
+ task.
174
+
78
175
  ## Preserve conversation with Sessions
79
176
 
80
177
  A **Session** lets one request continue an earlier conversation. Pass the same session ID and trusted actor ID with each related call:
@@ -106,7 +203,8 @@ Every Run has a session ID so LittleGhost can checkpoint its progress. If you do
106
203
 
107
204
  ## Stream or supervise long-running work
108
205
 
109
- `.stream_ask` runs on the caller's thread and yields `StreamEvent` values as the answer arrives:
206
+ `.stream_ask` runs on the caller's fiber or thread and yields `StreamEvent`
207
+ values as the answer arrives:
110
208
 
111
209
  ```ruby
112
210
  stream = CustomerSupportAgent.stream_ask(question)
@@ -138,7 +236,13 @@ execution.wait(deadline: Time.now + 30)
138
236
  execution.run.completed?
139
237
  ```
140
238
 
141
- The event block runs on the worker thread, so keep it quick. Cancellation, deadlines, and `close` ask the work to stop; they cannot forcibly end arbitrary provider or tool code. They also cannot undo actions that already happened.
239
+ The event block runs on the same fiber or thread as the Execution. With `:auto`,
240
+ `start_execution` uses a fiber when its caller is already in a scheduled fiber;
241
+ otherwise, it uses a worker thread. Keep the block quick because it slows event
242
+ delivery while it runs. Cancellation, deadlines, and `close` ask the work to
243
+ stop; they cannot forcibly end arbitrary provider or Tool code or undo actions
244
+ that already happened. Keep the application's scheduler running until an
245
+ Execution using it finishes or closes.
142
246
 
143
247
  ## Keep Tool permission checks in application code
144
248
 
@@ -239,14 +343,17 @@ agent = CustomerSupportAgent.new(runtime: runtime)
239
343
  An explicit Runtime has its own independent configuration. It does not replace
240
344
  LittleGhost's shared default.
241
345
 
242
- One Runtime can serve independent calls from several threads. Each call gets
243
- its own Run, participants, Tools, and Runtime-created Workspace and Sandbox. An
244
- Agent or Assembly already bound to an active Run must stay with that Run.
346
+ One Runtime can serve independent calls from several threads and fibers. Each
347
+ call gets its own Run, participants, Tools, and Runtime-created Workspace and
348
+ Sandbox. An Agent or Assembly already bound to an active Run must stay with
349
+ that Run.
245
350
 
246
351
  Within one SessionStore instance, LittleGhost serializes calls sharing a
247
352
  Session. Multi-process deployments need coordination from their store. Custom
248
- stores and other shared extension objects may receive concurrent calls and
249
- must be thread-safe.
353
+ stores and other shared extension objects may receive concurrent calls. Calls
354
+ can overlap on different threads, or fibers can take turns entering the same
355
+ object on one thread. Protect shared mutable state without relying on thread
356
+ identity.
250
357
 
251
358
  Runtime has no shutdown step. Shared services supplied by the application keep their own lifecycle. Shut those services down with the rest of your application. If you installed process-wide instrumentation subscribers, flush or shut down `LittleGhost::Instrumentation` during application shutdown.
252
359
 
data/docs/guides/tools.md CHANGED
@@ -220,6 +220,32 @@ class UpdateDraftTool < LittleGhost::Tool
220
220
  end
221
221
  ```
222
222
 
223
+ When LittleGhost selects the fiber backend, concurrent Tool calls can run as
224
+ fibers on one thread. If a Tool calls a library that blocks that thread, every
225
+ other fiber on it must wait too. Suppose the help center lookup later moves to
226
+ a client whose `lookup` method is documented to behave this way. Change only
227
+ the Tool method:
228
+
229
+ ```ruby
230
+ def call(input)
231
+ LittleGhost.offload_blocking do
232
+ HelpCenterClient.lookup(input.fetch("topic"))
233
+ end
234
+ end
235
+ ```
236
+
237
+ Many Ruby I/O calls already let the scheduler run other fibers. Keep those
238
+ calls unchanged. Use `offload_blocking` only when documentation or measurement
239
+ shows that the exact call pauses other fibers and the work can continue on
240
+ another Ruby thread. Configure the call's own timeout or cancellation when it
241
+ provides one.
242
+
243
+ If most of a Tool's implementation blocks, configure LittleGhost to use the
244
+ `:thread` backend instead of wrapping each call. `exclusive true` prevents
245
+ overlap with another exclusive Tool; it does not change where the Tool runs.
246
+ [Running in Production](production.md#use-an-existing-fiber-scheduler) explains
247
+ the concurrency settings and when to adjust the shared thread pool.
248
+
223
249
  Retries can repeat a Tool call. Prefer read-only operations, idempotency keys,
224
250
  or writes that are safe to apply more than once. Do not rely on the prompt to
225
251
  prevent duplicate side effects.
@@ -547,7 +547,7 @@ module LittleGhost
547
547
  template_resolver: nil,
548
548
  template_paths: [],
549
549
  run: nil,
550
- executor: Support::Executor.new,
550
+ executor: nil,
551
551
  delegation_activity: nil,
552
552
  agent_path: Subagents::AgentPath::ROOT,
553
553
  max_turns: 100,
@@ -591,7 +591,7 @@ module LittleGhost
591
591
  )
592
592
  @model_settings = model_settings.to_h.freeze
593
593
  @template_resolver = template_resolver || default_template_resolver(template_paths)
594
- @executor = executor
594
+ @executor = executor || Support::Executor.new(runner: task_runner)
595
595
  @delegation_activity = delegation_activity
596
596
  @agent_path = Subagents::AgentPath.validate!(agent_path)
597
597
  @max_turns = Integer(max_turns)
@@ -609,8 +609,8 @@ module LittleGhost
609
609
  raise ArgumentError, "max_turns must be at least 1" if @max_turns < 1
610
610
  raise ArgumentError, "max_tool_calls must be at least 1" if @max_tool_calls < 1
611
611
  raise ArgumentError, "max_tool_result_tokens must be at least 1" if @max_tool_result_tokens < 1
612
- @artifact_lifecycle = if runtime&.respond_to?(:runtime_hooks)
613
- runtime.runtime_hooks.find { |hook| hook.is_a?(Runtime::Hooks::Artifacts) }
612
+ @artifact_lifecycle = @runtime&.then do |resolved_runtime|
613
+ resolved_runtime.runtime_hooks.find { |hook| hook.is_a?(Runtime::Hooks::Artifacts) }
614
614
  end
615
615
  apply_cancellation_decision!(run_callbacks(:after_initialize, self))
616
616
  rescue
@@ -2126,7 +2126,7 @@ module LittleGhost
2126
2126
  declaration = self.class.code_mode_configuration
2127
2127
  return unless declaration
2128
2128
 
2129
- defaults = @runtime.respond_to?(:code_mode_configuration) ? @runtime.code_mode_configuration : nil
2129
+ defaults = @runtime&.code_mode_configuration
2130
2130
  @code_mode_declaration = (defaults || {}).merge(declaration).transform_keys(&:to_sym).freeze
2131
2131
  unknown = @code_mode_declaration.keys - %i[engine except sandbox limits]
2132
2132
  raise ConfigurationError, "Unknown code-mode option: #{unknown.first.inspect}" unless unknown.empty?
@@ -155,8 +155,8 @@ module LittleGhost
155
155
  def initialize(run: nil, runtime: nil, workspace: nil, sandbox: nil, standalone: run.nil?) # :nodoc:
156
156
  @run = run
157
157
  @runtime = runtime || run&.runtime || LittleGhost.runtime
158
- @workspace = workspace || (run.workspace if run&.respond_to?(:workspace))
159
- @sandbox = sandbox || (run.sandbox if run&.respond_to?(:sandbox))
158
+ @workspace = workspace || run&.workspace
159
+ @sandbox = sandbox || run&.sandbox
160
160
  @standalone = standalone
161
161
  @assembly_mutex = Mutex.new
162
162
  @assembly_closed = false
@@ -187,7 +187,7 @@ module LittleGhost
187
187
  runtime.build_run(payload, **options)
188
188
  end
189
189
 
190
- # Starts +payload+ on a supervised worker and returns an Execution.
190
+ # Starts +payload+ in the background and returns an Execution.
191
191
  # Composite assemblies include contextual +:agent_stream+ events in the
192
192
  # consumer by default. Set +include_agent_events+ to +false+ in +payload+
193
193
  # to keep only the ordinary public stream.
@@ -341,6 +341,10 @@ module LittleGhost
341
341
 
342
342
  def standalone? = @standalone # :nodoc:
343
343
 
344
+ def task_runner # :nodoc:
345
+ @task_runner ||= runtime ? runtime.task_runner : Support::TaskRunner.new
346
+ end
347
+
344
348
  def with_active_assembly(assembly) # :nodoc:
345
349
  @assembly_mutex.synchronize do
346
350
  raise Error, "assembly is already closed" if @assembly_closed
@@ -373,14 +377,8 @@ module LittleGhost
373
377
  end
374
378
 
375
379
  def build_tool_assembly
376
- builder = runtime.method(:build_assembly)
377
- accepts_path = builder.parameters.any? do |kind, name|
378
- kind == :keyrest || (%i[key keyreq].include?(kind) && name == :agent_stream_path)
379
- end
380
- options = {run:}
381
- options[:agent_stream_path] = agent_stream_path if accepts_path
382
- child = builder.call(self.class, **options)
383
- child.bind_agent_stream_path(agent_stream_path) if child.respond_to?(:bind_agent_stream_path)
380
+ child = runtime.build_assembly(self.class, run:, agent_stream_path:)
381
+ child.bind_agent_stream_path(agent_stream_path)
384
382
  child
385
383
  end
386
384
 
@@ -206,7 +206,7 @@ module LittleGhost
206
206
  (policy.fetch(:retries) + 1).times do |index|
207
207
  attempt_number = index + 1
208
208
  started_at = Time.now
209
- child = if build_options.any? && runtime.respond_to?(:build_agent)
209
+ child = if build_options.any?
210
210
  build_stream_participant(:build_agent, reference, stream_path, **build_options)
211
211
  else
212
212
  build_stream_participant(:build_assembly, reference, stream_path, **build_options)
@@ -254,8 +254,8 @@ module LittleGhost
254
254
  predecessor_ids:,
255
255
  branch_id:,
256
256
  participant:,
257
- assembly_id: child.class.respond_to?(:assembly_id) ? child.class.assembly_id : participant,
258
- assembly_kind: child.class.respond_to?(:assembly_kind) ? child.class.assembly_kind : :assembly,
257
+ assembly_id: child.class.assembly_id,
258
+ assembly_kind: child.class.assembly_kind,
259
259
  status: :completed,
260
260
  attempts:,
261
261
  usage:,
@@ -339,14 +339,8 @@ module LittleGhost
339
339
  end
340
340
 
341
341
  def build_stream_participant(builder_name, reference, stream_path, **options)
342
- builder = runtime.method(builder_name)
343
- parameters = builder.parameters
344
- accepts_path = parameters.any? do |kind, name|
345
- kind == :keyrest || (%i[key keyreq].include?(kind) && name == :agent_stream_path)
346
- end
347
- options[:agent_stream_path] = stream_path if accepts_path
348
- child = builder.call(reference, run:, **options)
349
- child.bind_agent_stream_path(stream_path) if child.respond_to?(:bind_agent_stream_path)
342
+ child = runtime.public_send(builder_name, reference, run:, agent_stream_path: stream_path, **options)
343
+ child.bind_agent_stream_path(stream_path)
350
344
  child
351
345
  end
352
346
 
@@ -420,11 +414,11 @@ module LittleGhost
420
414
  error.instance_variable_set(:@little_ghost_step_usage, usage)
421
415
  error.instance_variable_set(
422
416
  :@little_ghost_step_assembly_id,
423
- child.class.respond_to?(:assembly_id) ? child.class.assembly_id : participant.to_s
417
+ child.class.assembly_id
424
418
  )
425
419
  error.instance_variable_set(
426
420
  :@little_ghost_step_assembly_kind,
427
- child.class.respond_to?(:assembly_kind) ? child.class.assembly_kind : :assembly
421
+ child.class.assembly_kind
428
422
  )
429
423
  end
430
424
 
@@ -539,10 +533,7 @@ module LittleGhost
539
533
  def enqueue_assembly_event(queue, value, cancellation_token)
540
534
  loop do
541
535
  cancellation_token.raise_if_cancelled!
542
- queue.push(value, true)
543
- return
544
- rescue ThreadError
545
- cancellation_token.wait(0.01)
536
+ return if queue.push(value, timeout: 0.01)
546
537
  end
547
538
  end
548
539
 
@@ -14,6 +14,8 @@ module LittleGhost
14
14
  def initialize(agent: nil, registry: nil, context: RunContext.new, events: [],
15
15
  parent_operation_id: nil, parent_trace_context: nil, except: nil, dispatch: nil, max_calls: nil)
16
16
  @agent = agent
17
+ runtime = agent&.runtime
18
+ @task_runner = runtime ? runtime.task_runner : Support::TaskRunner.new
17
19
  @registry = registry || agent&.tool_registry
18
20
  @context = context
19
21
  @events = events
@@ -45,6 +47,8 @@ module LittleGhost
45
47
 
46
48
  attr_writer :context # :nodoc:
47
49
 
50
+ attr_reader :task_runner # :nodoc:
51
+
48
52
  def bind(context:, events:, parent_operation_id:, parent_trace_context: nil) # :nodoc:
49
53
  @context = context
50
54
  @events = events
@@ -14,11 +14,12 @@ module LittleGhost
14
14
 
15
15
  ToolCall = Data.define(:program_id, :call_id, :name, :arguments)
16
16
  ToolBatch = Data.define(:calls)
17
- Deadline = Struct.new(:thread, :cancelled, :expiring, :finished, :error)
17
+ Deadline = Struct.new(:task, :cancelled, :expiring, :finished, :error)
18
18
 
19
19
  def initialize(broker:, client:, sandbox: nil, workspace: nil, max_concurrency: 8,
20
20
  wall_seconds: 3_600, observation_seconds: OBSERVATION_SECONDS, cleanup_timeout: CLEANUP_TIMEOUT)
21
21
  @broker = broker
22
+ @task_runner = broker.task_runner
22
23
  @client = client
23
24
  @sandbox = sandbox
24
25
  @workspace = workspace
@@ -82,8 +83,12 @@ module LittleGhost
82
83
  begin
83
84
  @client.close_owner(self)
84
85
  wait_for_dispatches(program_ids)
85
- unless !worker || worker.join(@cleanup_timeout)
86
- raise LittleGhost::CleanupError, "Code-mode dispatch worker cleanup timed out"
86
+ if worker
87
+ begin
88
+ worker.wait(deadline: Time.now + @cleanup_timeout)
89
+ rescue LittleGhost::DeadlineExceededError
90
+ raise LittleGhost::CleanupError, "Code-mode dispatch worker cleanup timed out"
91
+ end
87
92
  end
88
93
  ensure
89
94
  @client.close
@@ -126,6 +131,7 @@ module LittleGhost
126
131
  @frames[program_id] = javascript_catalog
127
132
  @current_program_id = program_id
128
133
  end
134
+ ensure_worker if @task_runner.uses_current_scheduler?
129
135
  @client.start_program(
130
136
  owner: self, dispatcher: self, source:, tools: javascript_catalog.host_definitions, program_id:
131
137
  )
@@ -264,8 +270,7 @@ module LittleGhost
264
270
  def ensure_worker
265
271
  return if @worker&.alive?
266
272
 
267
- @worker = Thread.new { dispatch }
268
- @worker.report_on_exception = false
273
+ @worker = @task_runner.spawn { dispatch }
269
274
  end
270
275
 
271
276
  def drain_dispatch_queue
@@ -317,17 +322,12 @@ module LittleGhost
317
322
  invalid.each { |call| reject(call, "Unknown or invalid code-mode tool call: #{call.name}") }
318
323
  next if valid.empty?
319
324
 
320
- results = valid.each_slice(@max_concurrency).flat_map do |slice|
321
- threads = slice.map do |call|
322
- Thread.new do
323
- definition = catalog.fetch(call.name)
324
- @broker.call(
325
- definition.fetch("canonical_name"), call.arguments,
326
- id: "code-mode-#{call.program_id}-#{call.call_id}"
327
- )
328
- end.tap { |thread| thread.report_on_exception = false }
329
- end
330
- threads.map(&:value)
325
+ results = Support::Executor.new(max_concurrency: @max_concurrency, runner: @task_runner).map(valid) do |call|
326
+ definition = catalog.fetch(call.name)
327
+ @broker.call(
328
+ definition.fetch("canonical_name"), call.arguments,
329
+ id: "code-mode-#{call.program_id}-#{call.call_id}"
330
+ )
331
331
  end
332
332
  valid.zip(results).each do |call, result|
333
333
  if result.error.nil?
@@ -455,7 +455,7 @@ module LittleGhost
455
455
  state = Deadline.new(nil, false, false, false, nil)
456
456
  @deadline_mutex.synchronize do
457
457
  @deadlines[program_id] = state
458
- state.thread = Thread.new do
458
+ state.task = @task_runner.spawn do
459
459
  expired = @deadline_mutex.synchronize do
460
460
  loop do
461
461
  break false if state.cancelled
@@ -470,7 +470,6 @@ module LittleGhost
470
470
  rescue => error
471
471
  finish_deadline(program_id, state, error)
472
472
  end
473
- state.thread.report_on_exception = false
474
473
  end
475
474
  end
476
475
 
@@ -509,7 +508,7 @@ module LittleGhost
509
508
  end
510
509
 
511
510
  def cancel_deadline(program_id)
512
- thread = @deadline_mutex.synchronize do
511
+ task = @deadline_mutex.synchronize do
513
512
  state = @deadlines[program_id]
514
513
  return unless state
515
514
 
@@ -520,9 +519,9 @@ module LittleGhost
520
519
  @deadline_condition.broadcast
521
520
  end
522
521
  @deadlines.delete(program_id)
523
- state.thread
522
+ state.task
524
523
  end
525
- thread&.join unless thread.equal?(Thread.current)
524
+ task&.wait unless task&.current?
526
525
  end
527
526
 
528
527
  def cancel_all_deadlines
@@ -541,12 +540,12 @@ module LittleGhost
541
540
  end
542
541
 
543
542
  def raise_pending_deadline_error!
544
- error, thread = @deadline_mutex.synchronize do
543
+ error, task = @deadline_mutex.synchronize do
545
544
  pair = @pending_deadline_errors.shift
546
545
  state = @deadlines.delete(pair&.first)
547
- [pair&.last, state&.thread]
546
+ [pair&.last, state&.task]
548
547
  end
549
- thread&.join unless thread.equal?(Thread.current)
548
+ task&.wait unless task&.current?
550
549
  raise error if error
551
550
  end
552
551
 
@@ -14,6 +14,7 @@ module LittleGhost
14
14
 
15
15
  def initialize(broker:, sandbox_factory:, subprocess_policy:, limits:, observation_seconds: OBSERVATION_SECONDS)
16
16
  @broker = broker
17
+ @task_runner = broker.task_runner
17
18
  @sandbox_factory = sandbox_factory
18
19
  @subprocess_policy = subprocess_policy
19
20
  @limits = limits
@@ -28,7 +29,7 @@ module LittleGhost
28
29
  @control_mutex = Mutex.new
29
30
  @programs = 0
30
31
  @call_mutex = Mutex.new
31
- @call_threads = []
32
+ @call_tasks = []
32
33
  @call_errors = []
33
34
  @closing_marker = {closing: false}
34
35
  @lifecycle_mutex = Mutex.new
@@ -268,20 +269,18 @@ module LittleGhost
268
269
  end
269
270
 
270
271
  def dispatch_call(message, process)
271
- thread = @call_mutex.synchronize do
272
- active = @call_threads.count(&:alive?)
272
+ @call_mutex.synchronize do
273
+ active = @call_tasks.count(&:alive?)
273
274
  raise ProtocolError, "code-mode concurrent tool call limit exceeded" if active >= @limits.fetch(:concurrency)
274
275
 
275
276
  call_errors = @call_errors
276
277
  closing_marker = @closing_marker
277
- Thread.new do
278
+ @call_tasks << @task_runner.spawn do
278
279
  answer_call(message, process, closing_marker)
279
280
  rescue => error
280
281
  @call_mutex.synchronize { call_errors << error }
281
282
  end
282
283
  end
283
- thread.report_on_exception = false
284
- @call_mutex.synchronize { @call_threads << thread }
285
284
  end
286
285
 
287
286
  def append_output(value)
@@ -342,14 +341,14 @@ module LittleGhost
342
341
  end
343
342
  target, session, sandbox, workspace, directory = claimed
344
343
  watchdog = cancel_watchdog(target)
345
- threads, call_errors = @call_mutex.synchronize do
346
- current_threads = @call_threads
344
+ tasks, call_errors = @call_mutex.synchronize do
345
+ current_tasks = @call_tasks
347
346
  current_errors = @call_errors
348
347
  @closing_marker[:closing] = true
349
- @call_threads = []
348
+ @call_tasks = []
350
349
  @call_errors = []
351
350
  @closing_marker = {closing: false}
352
- [current_threads, current_errors]
351
+ [current_tasks, current_errors]
353
352
  end
354
353
  first_error = nil
355
354
  begin
@@ -358,17 +357,19 @@ module LittleGhost
358
357
  first_error ||= error
359
358
  end
360
359
  deadline = monotonic_time + @limits.fetch(:cleanup_seconds)
361
- threads.each do |thread|
360
+ tasks.each do |task|
362
361
  remaining = deadline - monotonic_time
363
362
  break unless remaining.positive?
364
363
 
365
364
  begin
366
- thread.join(remaining)
365
+ task.wait(deadline: Time.now + remaining)
366
+ rescue DeadlineExceededError
367
+ break
367
368
  rescue => error
368
369
  first_error ||= error
369
370
  end
370
371
  end
371
- if threads.any?(&:alive?)
372
+ if tasks.any?(&:alive?)
372
373
  @closed = true
373
374
  first_error ||= CleanupError.new("Code-mode nested tool cleanup timed out")
374
375
  end
@@ -389,14 +390,14 @@ module LittleGhost
389
390
  rescue => error
390
391
  first_error ||= error
391
392
  end
392
- watchdog&.join unless watchdog.equal?(Thread.current)
393
+ watchdog&.wait unless watchdog&.current?
393
394
  raise first_error if first_error
394
395
  end
395
396
 
396
397
  def start_watchdog(generation, deadline)
397
398
  @watchdog_mutex.synchronize do
398
399
  @watchdog_generation = generation
399
- @watchdog = Thread.new do
400
+ @watchdog = @task_runner.spawn do
400
401
  expired = @watchdog_mutex.synchronize do
401
402
  loop do
402
403
  break false unless @watchdog_generation.equal?(generation)
@@ -411,7 +412,6 @@ module LittleGhost
411
412
  rescue => error
412
413
  record_program_error(generation, error)
413
414
  end
414
- @watchdog.report_on_exception = false
415
415
  end
416
416
  end
417
417
 
@@ -419,11 +419,11 @@ module LittleGhost
419
419
  @watchdog_mutex.synchronize do
420
420
  return unless !generation || @watchdog_generation.equal?(generation)
421
421
 
422
- thread = @watchdog
422
+ task = @watchdog
423
423
  @watchdog_generation = nil
424
424
  @watchdog = nil
425
425
  @watchdog_condition.broadcast
426
- thread
426
+ task
427
427
  end
428
428
  end
429
429