little_ghost 0.1.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 (82) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +22 -0
  3. data/README.md +122 -0
  4. data/docs/guides/Core Concepts.md +203 -0
  5. data/docs/guides/Getting Started.md +187 -0
  6. data/lib/little_ghost/ag_ui/adapter.rb +194 -0
  7. data/lib/little_ghost/ag_ui.rb +5 -0
  8. data/lib/little_ghost/agent/context_management.rb +285 -0
  9. data/lib/little_ghost/agent/delegation.rb +128 -0
  10. data/lib/little_ghost/agent/skills.rb +96 -0
  11. data/lib/little_ghost/agent/tool_loop.rb +239 -0
  12. data/lib/little_ghost/agent.rb +2111 -0
  13. data/lib/little_ghost/agent_builder.rb +191 -0
  14. data/lib/little_ghost/agent_interruptions.rb +197 -0
  15. data/lib/little_ghost/configuration.rb +337 -0
  16. data/lib/little_ghost/content.rb +324 -0
  17. data/lib/little_ghost/default_model_registry.rb +71 -0
  18. data/lib/little_ghost/errors.rb +48 -0
  19. data/lib/little_ghost/events.rb +264 -0
  20. data/lib/little_ghost/execution_state.rb +58 -0
  21. data/lib/little_ghost/instrumentation.rb +475 -0
  22. data/lib/little_ghost/invocation.rb +285 -0
  23. data/lib/little_ghost/lookup.rb +37 -0
  24. data/lib/little_ghost/mcp/client.rb +396 -0
  25. data/lib/little_ghost/mcp.rb +5 -0
  26. data/lib/little_ghost/message.rb +75 -0
  27. data/lib/little_ghost/model.rb +88 -0
  28. data/lib/little_ghost/model_capabilities.rb +126 -0
  29. data/lib/little_ghost/model_registry.rb +173 -0
  30. data/lib/little_ghost/model_request.rb +107 -0
  31. data/lib/little_ghost/model_response.rb +48 -0
  32. data/lib/little_ghost/path_set.rb +32 -0
  33. data/lib/little_ghost/prompt_resolver.rb +251 -0
  34. data/lib/little_ghost/providers/bedrock.rb +506 -0
  35. data/lib/little_ghost/providers/http_transport.rb +149 -0
  36. data/lib/little_ghost/providers/open_router.rb +171 -0
  37. data/lib/little_ghost/providers/openai.rb +27 -0
  38. data/lib/little_ghost/providers/openai_compatible.rb +745 -0
  39. data/lib/little_ghost/providers/sse_parser.rb +35 -0
  40. data/lib/little_ghost/run.rb +607 -0
  41. data/lib/little_ghost/run_context.rb +129 -0
  42. data/lib/little_ghost/run_result.rb +111 -0
  43. data/lib/little_ghost/runtime/hook.rb +31 -0
  44. data/lib/little_ghost/runtime.rb +392 -0
  45. data/lib/little_ghost/sandbox.rb +138 -0
  46. data/lib/little_ghost/session.rb +229 -0
  47. data/lib/little_ghost/session_store.rb +96 -0
  48. data/lib/little_ghost/session_stores/agent_core_memory.rb +1086 -0
  49. data/lib/little_ghost/session_stores/memory.rb +86 -0
  50. data/lib/little_ghost/skills/catalog.rb +283 -0
  51. data/lib/little_ghost/skills/skill.rb +60 -0
  52. data/lib/little_ghost/skills.rb +4 -0
  53. data/lib/little_ghost/stream_event.rb +49 -0
  54. data/lib/little_ghost/structured_output.rb +126 -0
  55. data/lib/little_ghost/subagents/agent_path.rb +63 -0
  56. data/lib/little_ghost/subagents/definition.rb +42 -0
  57. data/lib/little_ghost/subagents/manager.rb +1615 -0
  58. data/lib/little_ghost/support/callbacks.rb +151 -0
  59. data/lib/little_ghost/support/cancellation_token.rb +86 -0
  60. data/lib/little_ghost/support/class_attributes.rb +40 -0
  61. data/lib/little_ghost/support/content_capture.rb +150 -0
  62. data/lib/little_ghost/support/executor.rb +75 -0
  63. data/lib/little_ghost/support/interruptible_stream.rb +103 -0
  64. data/lib/little_ghost/support/loader.rb +263 -0
  65. data/lib/little_ghost/support/output_truncation.rb +71 -0
  66. data/lib/little_ghost/support/redactor.rb +66 -0
  67. data/lib/little_ghost/support.rb +34 -0
  68. data/lib/little_ghost/tool.rb +448 -0
  69. data/lib/little_ghost/tool_execution.rb +59 -0
  70. data/lib/little_ghost/tool_registry.rb +156 -0
  71. data/lib/little_ghost/tools/filesystem.rb +119 -0
  72. data/lib/little_ghost/tools/shell.rb +45 -0
  73. data/lib/little_ghost/tools/write_todos.rb +91 -0
  74. data/lib/little_ghost/tools.rb +6 -0
  75. data/lib/little_ghost/tracing/open_telemetry.rb +517 -0
  76. data/lib/little_ghost/unrestricted_sandbox.rb +306 -0
  77. data/lib/little_ghost/usage.rb +47 -0
  78. data/lib/little_ghost/version.rb +6 -0
  79. data/lib/little_ghost/workflow.rb +351 -0
  80. data/lib/little_ghost/workspace.rb +31 -0
  81. data/lib/little_ghost.rb +120 -0
  82. metadata +225 -0
@@ -0,0 +1,1615 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "base64"
5
+ require "digest"
6
+ require "time"
7
+ require_relative "definition"
8
+
9
+ module LittleGhost
10
+ module Subagents
11
+ # Manager coordinates delegated conversations without making an application
12
+ # build its own worker pool or message protocol. It runs bounded concurrent
13
+ # tasks, queues follow-ups, reports progress, and can restore durable children.
14
+ #
15
+ # Applications normally enable it through the agent DSL:
16
+ #
17
+ # class CustomerSupportAgent < LittleGhost::Agent
18
+ # subagent ResearchAgent,
19
+ # kind: "research",
20
+ # description: "Investigates policies and account history"
21
+ # end
22
+ #
23
+ # LittleGhost then gives +CustomerSupportAgent+ tools to spawn, message, wait for,
24
+ # interrupt, and list research agents. The manager keeps each child identity
25
+ # stable across follow-up turns.
26
+ #
27
+ # Follow-up messages are FIFO turns and never interrupt active work.
28
+ # #interrupt is the separate synchronous path for delivery at the next model
29
+ # boundary; delivery does not stop the child, and tool calls from that model
30
+ # response continue in the child run.
31
+ #
32
+ # === Durability and cleanup
33
+ #
34
+ # With a parent session, durable definitions retain only committed compact
35
+ # transcripts and limited state snapshots. Failed or cancelled turns never
36
+ # become committed conversation history. Call #close to cancel and join
37
+ # workers owned by a directly constructed manager.
38
+ class Manager
39
+ # Raised when one or more managed workers cannot stop within the cleanup
40
+ # deadline.
41
+ class CleanupError < LittleGhost::CleanupError; end
42
+
43
+ DEFAULT_MAX_CONCURRENT = 8 # :nodoc:
44
+ DEFAULT_MAX_IDENTITIES = 20 # :nodoc:
45
+ DEFAULT_MAX_TURNS = 100 # :nodoc:
46
+ DEFAULT_MAX_QUEUED_TURNS_PER_IDENTITY = 8 # :nodoc:
47
+ DEFAULT_MAX_MESSAGE_CHARS = 50_000 # :nodoc:
48
+ DEFAULT_MAX_RESPONSE_CHARS = 100_000 # :nodoc:
49
+ DEFAULT_WAIT_TIMEOUT = 20.0 # :nodoc:
50
+ DEFAULT_CLOSE_TIMEOUT = 5.0 # :nodoc:
51
+ DEFAULT_LIST_LIMIT = 20 # :nodoc:
52
+ MAX_LIST_LIMIT = 100 # :nodoc:
53
+ MAX_PROGRESS_CHARS = 160 # :nodoc:
54
+ MAX_PROGRESS_SOURCE_CHARS = 4_096 # :nodoc:
55
+ PROGRESS_SEPARATOR = /[\p{Z}\p{Cc}\p{Cf}]/ # :nodoc:
56
+ CANCELLATION_POLL_INTERVAL = 0.05 # :nodoc:
57
+ REGISTRY_VERSION = 2 # :nodoc:
58
+ CURSOR_MAX_BYTES = 512 # :nodoc:
59
+ UUID_PATTERN = /\A[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/ # :nodoc:
60
+
61
+ InterruptExchange = Struct.new(:message, :response, :complete) # :nodoc:
62
+ Turn = Struct.new( # :nodoc:
63
+ :number,
64
+ :message,
65
+ :completion,
66
+ :operation_id,
67
+ :parent_operation_id,
68
+ :interrupts,
69
+ :interruption_metadata,
70
+ :interruption_ids
71
+ )
72
+ Identity = Struct.new( # :nodoc:
73
+ :subagent_id,
74
+ :conversation_id,
75
+ :definition,
76
+ :agent,
77
+ :session,
78
+ :durable,
79
+ :resumed,
80
+ :updated_at,
81
+ :committed_count,
82
+ :commit_id,
83
+ :commit_slot,
84
+ :history,
85
+ :state,
86
+ :queue,
87
+ :worker,
88
+ :status,
89
+ :next_turn,
90
+ :current_turn,
91
+ :current,
92
+ :latest_turn,
93
+ :latest_response,
94
+ :latest_response_turn,
95
+ :latest_response_truncated,
96
+ :latest_error,
97
+ :progress_message,
98
+ :progress_sequence
99
+ )
100
+
101
+ class Completion # :nodoc:
102
+ def initialize
103
+ @mutex = Mutex.new
104
+ @condition = ConditionVariable.new
105
+ @resolved = false
106
+ end
107
+
108
+ def resolve(value)
109
+ @mutex.synchronize do
110
+ return if @resolved
111
+
112
+ @value = value
113
+ @resolved = true
114
+ @condition.broadcast
115
+ end
116
+ end
117
+
118
+ def reject(error)
119
+ @mutex.synchronize do
120
+ return if @resolved
121
+
122
+ @error = error
123
+ @resolved = true
124
+ @condition.broadcast
125
+ end
126
+ end
127
+
128
+ def value(cancellation_token: nil, deadline: nil)
129
+ @mutex.synchronize do
130
+ until @resolved
131
+ cancellation_token&.raise_if_cancelled!
132
+ if deadline && Time.now >= deadline
133
+ raise DeadlineExceededError, "The run deadline was reached"
134
+ end
135
+
136
+ timeout = deadline ? [deadline - Time.now, 0.05].min : 0.05
137
+ @condition.wait(@mutex, [timeout, 0].max)
138
+ end
139
+ raise @error if @error
140
+
141
+ @value
142
+ end
143
+ end
144
+ end
145
+
146
+ class Capacity # :nodoc:
147
+ def initialize(limit)
148
+ @available = limit
149
+ @mutex = Mutex.new
150
+ @condition = ConditionVariable.new
151
+ end
152
+
153
+ def synchronize(cancellation_token, deadline: nil)
154
+ @mutex.synchronize do
155
+ while @available.zero?
156
+ cancellation_token.raise_if_cancelled!
157
+ if deadline && Time.now >= deadline
158
+ raise DeadlineExceededError, "The run deadline was reached"
159
+ end
160
+
161
+ timeout = deadline ? [deadline - Time.now, 0.05].min : 0.05
162
+ @condition.wait(@mutex, [timeout, 0].max)
163
+ end
164
+ @available -= 1
165
+ end
166
+
167
+ begin
168
+ yield
169
+ ensure
170
+ @mutex.synchronize do
171
+ @available += 1
172
+ @condition.signal
173
+ end
174
+ end
175
+ true
176
+ end
177
+ end
178
+
179
+ # Available definitions, indexed by kind.
180
+ attr_reader :definitions
181
+
182
+ class << self
183
+ # Produces a pseudonymous parent-session link for durable metadata.
184
+ def parent_link(session)
185
+ Digest::SHA256.hexdigest("#{session.actor_id}\0#{session.id}")
186
+ end
187
+
188
+ # Derives the framework-owned registry session ID.
189
+ def registry_session_id(session)
190
+ "lg_subagent_registry_#{parent_link(session)}"
191
+ end
192
+
193
+ # Derives the framework-owned transcript session ID.
194
+ def conversation_session_id(conversation_id)
195
+ "lg_subagent_conversation_#{conversation_id}"
196
+ end
197
+
198
+ # Derives one of the rotating committed-state session IDs.
199
+ def commit_session_id(conversation_id, slot)
200
+ "lg_subagent_commit_#{conversation_id}_#{slot}"
201
+ end
202
+ end
203
+
204
+ # Configures a bounded manager. Durable restoration is enabled only when
205
+ # +parent_session+ is supplied.
206
+ def initialize(
207
+ definitions,
208
+ runtime: nil,
209
+ max_concurrent: DEFAULT_MAX_CONCURRENT,
210
+ max_identities: DEFAULT_MAX_IDENTITIES,
211
+ max_turns: DEFAULT_MAX_TURNS,
212
+ max_queued_turns_per_identity: DEFAULT_MAX_QUEUED_TURNS_PER_IDENTITY,
213
+ max_message_chars: DEFAULT_MAX_MESSAGE_CHARS,
214
+ max_response_chars: DEFAULT_MAX_RESPONSE_CHARS,
215
+ wait_timeout: DEFAULT_WAIT_TIMEOUT,
216
+ close_timeout: DEFAULT_CLOSE_TIMEOUT,
217
+ cancellation_token: Support::CancellationToken.new,
218
+ deadline: nil,
219
+ observer: nil,
220
+ parent_session: nil,
221
+ parent_agent_path: AgentPath::ROOT
222
+ )
223
+ @runtime = runtime
224
+ validate_limit(:max_concurrent, max_concurrent)
225
+ validate_limit(:max_identities, max_identities)
226
+ validate_limit(:max_turns, max_turns)
227
+ validate_limit(:max_queued_turns_per_identity, max_queued_turns_per_identity)
228
+ validate_limit(:max_message_chars, max_message_chars)
229
+ validate_limit(:max_response_chars, max_response_chars)
230
+ validate_timeout(:wait_timeout, wait_timeout)
231
+ validate_timeout(:close_timeout, close_timeout)
232
+
233
+ @definitions = definitions.each_with_object({}) do |definition, index|
234
+ raise ArgumentError, "Duplicate subagent kind: #{definition.kind}" if index.key?(definition.kind)
235
+
236
+ index[definition.kind] = definition
237
+ end.freeze
238
+ @max_identities = max_identities
239
+ @max_turns = max_turns
240
+ @max_queued_turns_per_identity = max_queued_turns_per_identity
241
+ @max_message_chars = max_message_chars
242
+ @max_response_chars = max_response_chars
243
+ @wait_timeout = wait_timeout
244
+ @close_timeout = close_timeout
245
+ @cancellation_token = cancellation_token.child
246
+ @deadline = deadline
247
+ @observer = observer
248
+ @parent_session = parent_session
249
+ @parent_agent_path = AgentPath.validate!(parent_agent_path)
250
+ @parent_link = parent_session && self.class.parent_link(parent_session)
251
+ @registry_session = parent_session && registry_session
252
+ @capacity = Capacity.new(max_concurrent)
253
+ @mutex = Mutex.new
254
+ @registry_mutex = Mutex.new
255
+ @restore_mutex = Mutex.new
256
+ @condition = ConditionVariable.new
257
+ @identities = {}
258
+ @reserved_agent_paths = {}
259
+ @identity_slots = 0
260
+ @turn_count = 0
261
+ @closed = false
262
+ restore_identities
263
+ end
264
+
265
+ # Creates a unique child identity and queues its first task.
266
+ #
267
+ # +mode+ is <tt>"sync"</tt> or <tt>"async"</tt>. Synchronous mode waits for the turn;
268
+ # asynchronous mode returns a working snapshot for later #wait calls.
269
+ def spawn(kind:, task_name:, task:, mode:, parent_operation_id: nil, context: nil)
270
+ validate_mode(mode)
271
+ definition, subagent_id = reserve_identity(kind, task, task_name:)
272
+ return subagent_id unless definition
273
+
274
+ conversation_id = SecureRandom.uuid
275
+ begin
276
+ agent = build_agent(definition, subagent_id, conversation_id)
277
+ raise TypeError, "factory result must respond to call" unless agent.respond_to?(:call)
278
+ rescue LittleGhost::CleanupError
279
+ release_identity_reservation(subagent_id)
280
+ raise
281
+ rescue => error
282
+ release_identity_reservation(subagent_id)
283
+ warn_failure("factory", subagent_id, error)
284
+ emit_factory_failure(definition, subagent_id, error, parent_operation_id:)
285
+ return {
286
+ status: "failed",
287
+ subagent_id: subagent_id,
288
+ kind: definition.kind,
289
+ error: "Subagent could not be created."
290
+ }
291
+ end
292
+
293
+ identity = Identity.new(
294
+ subagent_id: subagent_id,
295
+ conversation_id: conversation_id,
296
+ definition: definition,
297
+ agent: agent,
298
+ session: definition.persist && @parent_session && child_session(conversation_id),
299
+ durable: definition.persist && !!@parent_session,
300
+ resumed: false,
301
+ updated_at: Time.now.utc.iso8601(6),
302
+ committed_count: 0,
303
+ commit_slot: 1,
304
+ history: [].freeze,
305
+ state: {},
306
+ queue: [],
307
+ status: "idle",
308
+ next_turn: 1,
309
+ latest_response_truncated: false,
310
+ progress_sequence: 0
311
+ )
312
+ observe_delegated_activity(identity)
313
+
314
+ closed = @mutex.synchronize do
315
+ if @closed
316
+ @reserved_agent_paths.delete(subagent_id)
317
+ @identity_slots -= 1
318
+ @turn_count -= 1
319
+ next true
320
+ end
321
+ @reserved_agent_paths.delete(subagent_id)
322
+ @identities[subagent_id] = identity
323
+ false
324
+ end
325
+ if closed
326
+ agent.close if agent.respond_to?(:close)
327
+ raise Error, "Subagent manager is closed"
328
+ end
329
+
330
+ turn, queued_snapshot = enqueue(
331
+ identity,
332
+ task,
333
+ event: "spawned",
334
+ count_turn: false,
335
+ parent_operation_id:,
336
+ context:
337
+ )
338
+ return {status: "working", subagent: queued_snapshot} if mode == "async"
339
+
340
+ turn.completion.value(cancellation_token: @cancellation_token, deadline: @deadline)
341
+ end
342
+
343
+ # Queues a FIFO follow-up for an active or durable identity.
344
+ def send_message(subagent_id:, message:, mode:, parent_operation_id: nil, context: nil)
345
+ validate_mode(mode)
346
+ identity = @mutex.synchronize do
347
+ ensure_open!
348
+ fetch_identity!(subagent_id)
349
+ end
350
+ restore_agent!(identity)
351
+ queued = enqueue(
352
+ identity,
353
+ message,
354
+ event: "message_queued",
355
+ enforce_limits: true,
356
+ parent_operation_id:,
357
+ context:
358
+ )
359
+ return queued if queued.is_a?(Hash)
360
+
361
+ turn, queued_snapshot = queued
362
+ return {status: "working", subagent: queued_snapshot} if mode == "async"
363
+
364
+ turn.completion.value(cancellation_token: @cancellation_token, deadline: @deadline)
365
+ end
366
+
367
+ # Delivers +message+ to one currently running turn and waits for the next
368
+ # model response. The returned +response_disposition+ says whether that
369
+ # response also initiated tool calls; it does not imply the subagent has
370
+ # stopped.
371
+ def interrupt(subagent_id:, message:, cancellation_token: @cancellation_token, deadline: @deadline)
372
+ unless message.is_a?(String)
373
+ raise ToolError, "Subagent messages must be strings."
374
+ end
375
+ if message.length > @max_message_chars
376
+ raise ToolError, "Subagent messages cannot exceed #{@max_message_chars} characters."
377
+ end
378
+
379
+ exchange = InterruptExchange.new(message:, complete: false)
380
+ identity, turn = @mutex.synchronize do
381
+ ensure_open!
382
+ value = fetch_identity!(subagent_id)
383
+ unless value.agent.respond_to?(:interrupt_response)
384
+ raise ToolError, "Subagent #{subagent_id.inspect} does not support interruptions."
385
+ end
386
+ unless value.status == "running"
387
+ raise ToolError, "Subagent #{subagent_id.inspect} is not currently running."
388
+ end
389
+ if value.current.interrupts.length >= @max_queued_turns_per_identity
390
+ raise ToolError, "Subagent #{subagent_id.inspect} has reached its interrupt limit."
391
+ end
392
+ interrupt_chars = value.current.interrupts.sum { |pending| pending.message.length }
393
+ if interrupt_chars + message.length > @max_message_chars
394
+ raise ToolError, "Subagent interrupt messages cannot exceed #{@max_message_chars} total characters."
395
+ end
396
+
397
+ value.current.interrupts << exchange
398
+ [value, value.current]
399
+ end
400
+
401
+ interrupt_response = begin
402
+ identity.agent.interrupt_response(
403
+ message,
404
+ cancellation_token:,
405
+ deadline:,
406
+ target_operation_id: turn.operation_id
407
+ )
408
+ rescue
409
+ @mutex.synchronize do
410
+ turn.interrupts.delete(exchange)
411
+ @condition.broadcast
412
+ end
413
+ raise
414
+ end
415
+ response = interrupt_response.text
416
+ truncated = response.length > @max_response_chars
417
+ returned_response = truncated ? response[0, @max_response_chars] : response
418
+ @mutex.synchronize do
419
+ used_response_chars = turn.interrupts.sum do |pending|
420
+ pending.equal?(exchange) ? 0 : pending.response.to_s.length
421
+ end
422
+ remaining_response_chars = [@max_response_chars - used_response_chars, 0].max
423
+ exchange.response = returned_response[0, remaining_response_chars]
424
+ exchange.complete = true
425
+ @condition.broadcast
426
+ end
427
+ subagent = @mutex.synchronize do
428
+ snapshot(identity, include_response: true, include_progress: true)
429
+ end
430
+ value = {
431
+ status: "interruption_delivered",
432
+ subagent_id: identity.subagent_id,
433
+ kind: identity.definition.kind,
434
+ subagent:,
435
+ turn: turn.number,
436
+ response: returned_response,
437
+ response_disposition: interrupt_response.tool_calls? ? "text_with_tool_calls" : "text_only"
438
+ }
439
+ value[:response_truncated] = true if truncated
440
+ value
441
+ rescue AgentInterruptError => error
442
+ raise ToolError, error.message
443
+ end
444
+
445
+ # Long-polls selected identities, or all identities when omitted.
446
+ # +still_working+ is an ordinary timeout result and does not cancel work.
447
+ def wait(subagent_ids: nil)
448
+ identities = @mutex.synchronize do
449
+ ensure_open!
450
+ selected_identities(subagent_ids)
451
+ end
452
+ return {status: "finished", subagents: []} if identities.empty?
453
+
454
+ deadline = monotonic_time + @wait_timeout
455
+ @mutex.synchronize do
456
+ until identities.all? { |identity| finished?(identity) }
457
+ @cancellation_token.raise_if_cancelled!
458
+ if @deadline && Time.now >= @deadline
459
+ raise DeadlineExceededError, "The run deadline was reached"
460
+ end
461
+
462
+ remaining = deadline - monotonic_time
463
+ remaining = [remaining, @deadline - Time.now].min if @deadline
464
+ break unless remaining.positive?
465
+
466
+ @condition.wait(@mutex, [remaining, CANCELLATION_POLL_INTERVAL].min)
467
+ end
468
+ status = (identities.all? { |identity| finished?(identity) }) ? "finished" : "still_working"
469
+ {
470
+ status: status,
471
+ subagents: identities.map { |identity| snapshot(identity, include_response: true, include_progress: true) }
472
+ }
473
+ end
474
+ end
475
+
476
+ # Lists active and persisted identities newest-first without restoring
477
+ # inactive agents. Cursors are opaque and must be passed back unchanged.
478
+ def list(kind: nil, limit: DEFAULT_LIST_LIMIT, cursor: nil)
479
+ cursor = nil if cursor == ""
480
+ unless limit.is_a?(Integer) && limit.between?(1, MAX_LIST_LIMIT)
481
+ raise ToolError, "limit must be between 1 and #{MAX_LIST_LIMIT}"
482
+ end
483
+ if kind && !definitions.key?(kind)
484
+ raise ToolError, "Unknown subagent kind: #{kind}"
485
+ end
486
+
487
+ @mutex.synchronize do
488
+ identities = @identities.values
489
+ identities = identities.select { |identity| identity.definition.kind == kind } if kind
490
+ identities = identities.sort_by { |identity| [identity.updated_at.to_s, identity.subagent_id] }.reverse
491
+ if cursor
492
+ boundary = decode_cursor(cursor)
493
+ identities = identities.drop_while do |identity|
494
+ ([identity.updated_at.to_s, identity.subagent_id] <=> boundary) >= 0
495
+ end
496
+ end
497
+ page = identities.first(limit)
498
+ value = {
499
+ status: "ok",
500
+ subagents: page.map { |identity| snapshot(identity, include_progress: true) }
501
+ }
502
+ value[:next_cursor] = encode_cursor(page.last) if identities.length > page.length
503
+ value
504
+ end
505
+ end
506
+
507
+ # Builds spawn, follow-up, interrupt, wait, and list tools bound to this
508
+ # manager. Closing the first tool closes the shared manager.
509
+ def tools
510
+ manager = self
511
+ kind_descriptions = definitions.values.map do |definition|
512
+ "- #{definition.kind}: #{definition.description}"
513
+ end.join("\n")
514
+ tools = [
515
+ Tool.define(
516
+ name: "spawn_subagent",
517
+ description: <<~DESCRIPTION.strip,
518
+ Create a new subagent identity for an independent task. Mode controls delivery: sync waits for the
519
+ response in this call, while async returns immediately and leaves the response for
520
+ wait_for_subagents. Several sync spawns requested together can still run in parallel. Give the task a
521
+ concise lowercase name. The returned identity is its canonical path beneath the current agent. Task
522
+ names must be unique among that agent's children.
523
+ DESCRIPTION
524
+ input_schema: {
525
+ type: "object",
526
+ properties: {
527
+ kind: {
528
+ type: "string",
529
+ enum: definitions.keys,
530
+ description: "Kind of subagent to create.\n#{kind_descriptions}"
531
+ },
532
+ task_name: {
533
+ type: "string",
534
+ pattern: "^[a-z0-9_]+$",
535
+ maxLength: AgentPath::MAX_NAME_LENGTH,
536
+ description: "Friendly task name using lowercase letters, digits, and underscores."
537
+ },
538
+ task: {type: "string", description: "Independent task to delegate."},
539
+ mode: {
540
+ type: "string", enum: %w[sync async],
541
+ description: "sync waits for the response; async returns while the subagent continues."
542
+ }
543
+ },
544
+ required: %w[kind task_name task mode],
545
+ additionalProperties: false
546
+ }
547
+ ) do |input, context: nil|
548
+ manager.spawn(
549
+ kind: input.fetch("kind"),
550
+ task_name: input.fetch("task_name"),
551
+ task: input.fetch("task"),
552
+ mode: input.fetch("mode"),
553
+ context:,
554
+ parent_operation_id: context&.agent_operation_id
555
+ )
556
+ end,
557
+ Tool.define(
558
+ name: "send_message_to_subagent",
559
+ description: <<~DESCRIPTION.strip,
560
+ Send a follow-up turn to an existing active or persisted subagent identity. Persisted conversations
561
+ are restored transparently before the follow-up. Messages are processed in order after the
562
+ current turn and never interrupt active work. Do not use this for status, steering, stopping, or
563
+ finalization; use interrupt_subagent for an active subagent. Mode controls delivery: sync waits for the
564
+ later turn's response, while async enqueues the turn and returns immediately.
565
+ DESCRIPTION
566
+ input_schema: {
567
+ type: "object",
568
+ properties: {
569
+ subagent_id: {type: "string", description: "Existing subagent identity."},
570
+ message: {type: "string", description: "Follow-up task or context."},
571
+ mode: {
572
+ type: "string", enum: %w[sync async],
573
+ description: "sync waits for this turn; async enqueues it and returns immediately."
574
+ }
575
+ },
576
+ required: %w[subagent_id message mode],
577
+ additionalProperties: false
578
+ }
579
+ ) do |input, context: nil|
580
+ manager.send_message(
581
+ subagent_id: input.fetch("subagent_id"),
582
+ message: input.fetch("message"),
583
+ mode: input.fetch("mode"),
584
+ context:,
585
+ parent_operation_id: context&.agent_operation_id
586
+ )
587
+ end,
588
+ Tool.define(
589
+ name: "interrupt_subagent",
590
+ description: <<~DESCRIPTION.strip,
591
+ Interrupt an actively running subagent in its current turn. The message is added at the next model
592
+ boundary. This call waits for that model response and reports its ordinary text, whether the same
593
+ response also initiated tool work, and the subagent's current lifecycle state. Delivery is distinct
594
+ from stopping: tool work from that response remains with the subagent and its current run may continue.
595
+ DESCRIPTION
596
+ input_schema: {
597
+ type: "object",
598
+ properties: {
599
+ subagent_id: {type: "string", description: "Actively running subagent identity."},
600
+ message: {type: "string", description: "Status question, steering context, or request to finish."}
601
+ },
602
+ required: %w[subagent_id message],
603
+ additionalProperties: false
604
+ }
605
+ ) do |input, context: nil|
606
+ options = {}
607
+ options[:cancellation_token] = context.cancellation_token if context
608
+ options[:deadline] = context.deadline if context&.deadline
609
+ manager.interrupt(
610
+ subagent_id: input.fetch("subagent_id"),
611
+ message: input.fetch("message"),
612
+ **options
613
+ )
614
+ end,
615
+ Tool.define(
616
+ name: "wait_for_subagents",
617
+ description: <<~DESCRIPTION.strip,
618
+ Wait briefly for selected subagents, or all subagents when omitted. A still_working response is expected
619
+ when work takes longer than this check-in window. Call this tool again to keep waiting; timeout is not an
620
+ error and does not cancel the subagents. A successful settled turn is returned as response. When newer
621
+ work is queued, running, persisting, failed, or cancelled, the most recent successful result may instead
622
+ appear as previous_response for context; it is not the result of that newer work. Inspect each subagent's
623
+ status and keep waiting while selected work is active.
624
+ DESCRIPTION
625
+ input_schema: {
626
+ type: "object",
627
+ properties: {
628
+ subagent_ids: {
629
+ type: "array", items: {type: "string"},
630
+ description: "Subagent identities to wait for; omit to wait for all."
631
+ }
632
+ },
633
+ additionalProperties: false
634
+ }
635
+ ) { |input| manager.wait(subagent_ids: input["subagent_ids"]) },
636
+ Tool.define(
637
+ name: "list_subagents",
638
+ description: <<~DESCRIPTION.strip,
639
+ List active and persisted subagent conversations newest-first without restoring inactive agents.
640
+ Use kind to filter. Omit cursor for the first page; to continue, pass the exact non-empty next_cursor
641
+ from the preceding result.
642
+ DESCRIPTION
643
+ input_schema: {
644
+ type: "object",
645
+ properties: {
646
+ kind: {type: "string", enum: definitions.keys},
647
+ limit: {type: "integer", minimum: 1, maximum: MAX_LIST_LIMIT},
648
+ cursor: {type: "string"}
649
+ },
650
+ additionalProperties: false
651
+ }
652
+ ) do |input|
653
+ manager.list(
654
+ kind: input["kind"],
655
+ limit: input.fetch("limit", DEFAULT_LIST_LIMIT),
656
+ cursor: input["cursor"]
657
+ )
658
+ end
659
+ ]
660
+ tools.first.define_method(:close) { manager.close }
661
+ tools
662
+ end
663
+
664
+ # Cancels queued work, cooperatively stops workers, and closes child
665
+ # agents. Raises CleanupError if workers do not stop within the bound.
666
+ def close
667
+ workers = @mutex.synchronize do
668
+ return if @closed
669
+
670
+ @closed = true
671
+ @cancellation_token.cancel
672
+ @identities.each_value do |identity|
673
+ next if %w[idle failed cancelled persisting].include?(identity.status)
674
+
675
+ turn = identity.current
676
+ identity.status = "cancelled"
677
+ turn&.completion&.resolve(cancelled_turn(identity, turn))
678
+ identity.progress_message = nil
679
+ identity.current_turn = nil
680
+ identity.current = nil
681
+ cancel_queued_turns(identity)
682
+ emit("cancelled", identity, turn:)
683
+ end
684
+ @condition.broadcast
685
+ @identities.values.filter_map(&:worker)
686
+ end
687
+
688
+ deadline = monotonic_time + @close_timeout
689
+ cooperative_deadline = monotonic_time + (@close_timeout / 2.0)
690
+ workers.each do |worker|
691
+ remaining = cooperative_deadline - monotonic_time
692
+ break unless remaining.positive?
693
+
694
+ worker.join(remaining)
695
+ end
696
+ workers.select(&:alive?).each(&:kill)
697
+ workers.each do |worker|
698
+ remaining = deadline - monotonic_time
699
+ break unless remaining.positive?
700
+
701
+ worker.join(remaining)
702
+ end
703
+ first_error = nil
704
+ survivors = workers.select(&:alive?)
705
+ unless survivors.empty?
706
+ first_error ||= CleanupError.new(
707
+ "#{survivors.length} subagent worker(s) did not stop within #{@close_timeout} seconds"
708
+ )
709
+ end
710
+ agents = @mutex.synchronize { @identities.values.map(&:agent).reverse.uniq(&:object_id) }
711
+ agents.each do |agent|
712
+ agent.close if agent.respond_to?(:close)
713
+ rescue => error
714
+ first_error ||= error
715
+ end
716
+ raise first_error if first_error
717
+ end
718
+
719
+ private
720
+
721
+ def restore_identities
722
+ return unless @registry_session
723
+
724
+ registry = @registry_session.state
725
+ validate_session_metadata!(@registry_session, registry_metadata)
726
+ version = registry["version"] || registry[:version]
727
+ return unless version == REGISTRY_VERSION
728
+
729
+ conversations = registry["conversations"] || registry[:conversations]
730
+ return unless conversations.is_a?(Hash)
731
+
732
+ restored = conversations.filter_map do |subagent_id, record|
733
+ normalized = normalize_registry_record(subagent_id, record)
734
+ next unless normalized
735
+
736
+ definition = @definitions[normalized.fetch(:kind)]
737
+ next unless definition&.persist
738
+
739
+ conversation_id = normalized.fetch(:conversation_id)
740
+ session = child_session(conversation_id)
741
+ Identity.new(
742
+ subagent_id: normalized.fetch(:subagent_id),
743
+ conversation_id:,
744
+ definition:,
745
+ agent: nil,
746
+ session:,
747
+ durable: true,
748
+ resumed: true,
749
+ updated_at: normalized.fetch(:updated_at),
750
+ committed_count: normalized.fetch(:message_count),
751
+ commit_id: normalized.fetch(:commit_id),
752
+ commit_slot: normalized.fetch(:commit_slot),
753
+ history: [].freeze,
754
+ state: {},
755
+ queue: [],
756
+ status: "idle",
757
+ next_turn: normalized.fetch(:latest_turn) + 1,
758
+ latest_turn: normalized.fetch(:latest_turn),
759
+ latest_response_truncated: false,
760
+ progress_sequence: 0
761
+ )
762
+ end
763
+ restored.sort_by { |identity| [identity.updated_at, identity.subagent_id] }.reverse
764
+ .first(@max_identities)
765
+ .each { |identity| @identities[identity.subagent_id] = identity }
766
+ rescue ProtocolError
767
+ raise
768
+ rescue => error
769
+ raise ProtocolError, "Subagent conversation registry is invalid: #{error.class}"
770
+ end
771
+
772
+ def registry_session
773
+ Session.new(
774
+ id: self.class.registry_session_id(@parent_session),
775
+ actor_id: @parent_session.actor_id,
776
+ store: @parent_session.store,
777
+ operation_id: @parent_session.operation_id,
778
+ metadata: registry_metadata
779
+ )
780
+ end
781
+
782
+ def child_session(conversation_id)
783
+ Session.new(
784
+ id: self.class.conversation_session_id(conversation_id),
785
+ actor_id: @parent_session.actor_id,
786
+ store: @parent_session.store,
787
+ operation_id: @parent_session.operation_id,
788
+ metadata: child_metadata(conversation_id)
789
+ )
790
+ end
791
+
792
+ def commit_session(conversation_id, slot, commit_id, message_count)
793
+ Session.new(
794
+ id: self.class.commit_session_id(conversation_id, slot),
795
+ actor_id: @parent_session.actor_id,
796
+ store: @parent_session.store,
797
+ operation_id: @parent_session.operation_id,
798
+ metadata: commit_metadata(conversation_id, commit_id, message_count)
799
+ )
800
+ end
801
+
802
+ def load_committed_state(identity)
803
+ commit = commit_session(
804
+ identity.conversation_id,
805
+ identity.commit_slot,
806
+ identity.commit_id,
807
+ identity.committed_count
808
+ )
809
+ snapshot = commit.load
810
+ validate_session_metadata!(
811
+ commit,
812
+ commit_metadata(identity.conversation_id, identity.commit_id, identity.committed_count)
813
+ )
814
+ raise ProtocolError, "Subagent committed state snapshot is missing" unless snapshot
815
+
816
+ snapshot.fetch(:state)
817
+ end
818
+
819
+ def build_agent(definition, subagent_id, conversation_id)
820
+ agent = if definition.accepts_conversation_id
821
+ invoke_factory(definition.factory, subagent_id, conversation_id)
822
+ else
823
+ invoke_factory(definition.factory, subagent_id)
824
+ end
825
+ if agent.is_a?(Agent) && agent.agent_path != subagent_id
826
+ raise ConfigurationError,
827
+ "Subagent factory built #{agent.agent_path.inspect}; it must use canonical agent_path #{subagent_id.inspect}."
828
+ end
829
+
830
+ agent
831
+ end
832
+
833
+ def invoke_factory(factory, *arguments)
834
+ accepts_runtime = factory.parameters.any? do |kind, name|
835
+ %i[key keyreq keyrest].include?(kind) && (name == :runtime || kind == :keyrest)
836
+ end
837
+ accepts_runtime ? factory.call(*arguments, runtime: @runtime) : factory.call(*arguments)
838
+ end
839
+
840
+ def restore_agent!(identity)
841
+ return if identity.agent
842
+
843
+ agent = nil
844
+ @restore_mutex.synchronize do
845
+ return if identity.agent
846
+
847
+ snapshot = identity.session.load
848
+ validate_session_metadata!(identity.session, child_metadata(identity.conversation_id))
849
+ committed_state = load_committed_state(identity)
850
+ messages = snapshot&.fetch(:messages) || []
851
+ snapshot_state = snapshot&.fetch(:state) || {}
852
+ if messages.length < identity.committed_count
853
+ raise ProtocolError, "Subagent conversation is shorter than its committed boundary"
854
+ end
855
+ identity.history = messages.first(identity.committed_count).freeze
856
+ identity.state = mutable_copy(committed_state)
857
+ if messages.length != identity.committed_count ||
858
+ snapshot_state != committed_state
859
+ identity.session.replace(
860
+ messages: identity.history,
861
+ state: identity.state,
862
+ metadata: child_metadata(identity.conversation_id)
863
+ )
864
+ end
865
+ agent = build_agent(identity.definition, identity.subagent_id, identity.conversation_id)
866
+ raise TypeError, "factory result must respond to call" unless agent.respond_to?(:call)
867
+
868
+ @mutex.synchronize do
869
+ ensure_open!
870
+ identity.agent = agent
871
+ end
872
+ end
873
+ rescue LittleGhost::CleanupError
874
+ agent.close if agent&.respond_to?(:close)
875
+ raise
876
+ rescue => error
877
+ agent.close if agent&.respond_to?(:close)
878
+ warn_failure("factory", identity.subagent_id, error)
879
+ emit_factory_failure(identity.definition, identity.subagent_id, error)
880
+ raise ToolError, "Subagent could not be restored."
881
+ end
882
+
883
+ def persist_registry(identity, message_count:, state:)
884
+ return unless @registry_session
885
+
886
+ @registry_mutex.synchronize do
887
+ commit_id = SecureRandom.uuid
888
+ commit_slot = (identity.commit_slot == 0) ? 1 : 0
889
+ commit = commit_session(identity.conversation_id, commit_slot, commit_id, message_count)
890
+ commit.replace(
891
+ messages: [],
892
+ state:,
893
+ metadata: commit_metadata(identity.conversation_id, commit_id, message_count)
894
+ )
895
+ @registry_session.synchronize do
896
+ current = registry_session
897
+ registry = current.state
898
+ validate_session_metadata!(current, registry_metadata)
899
+ registry = {"version" => REGISTRY_VERSION, "conversations" => {}} unless
900
+ (registry["version"] || registry[:version]) == REGISTRY_VERSION
901
+ conversations = registry["conversations"] ||= {}
902
+ identity.updated_at = Time.now.utc.iso8601(6)
903
+ conversations[identity.subagent_id] = {
904
+ "conversation_id" => identity.conversation_id,
905
+ "kind" => identity.definition.kind,
906
+ "latest_turn" => identity.current.number,
907
+ "updated_at" => identity.updated_at,
908
+ "message_count" => message_count,
909
+ "commit_id" => commit_id,
910
+ "commit_slot" => commit_slot
911
+ }
912
+ retained = conversations.filter_map do |subagent_id, record|
913
+ normalized = normalize_registry_record(subagent_id, record)
914
+ definition = normalized && @definitions[normalized.fetch(:kind)]
915
+ [subagent_id, record] if normalized && definition&.persist
916
+ end.sort_by do |subagent_id, record|
917
+ [(record["updated_at"] || record[:updated_at]).to_s, subagent_id]
918
+ end.reverse.first(@max_identities).to_h
919
+ registry["conversations"] = retained
920
+ current.replace(messages: [], state: registry, metadata: registry_metadata)
921
+ @registry_session = current
922
+ identity.commit_id = commit_id
923
+ identity.commit_slot = commit_slot
924
+ end
925
+ end
926
+ end
927
+
928
+ def registry_metadata
929
+ {
930
+ "little_ghost_kind" => "subagent_registry",
931
+ "little_ghost_parent_link" => @parent_link
932
+ }
933
+ end
934
+
935
+ def child_metadata(conversation_id)
936
+ {
937
+ "little_ghost_kind" => "subagent_conversation",
938
+ "little_ghost_parent_link" => @parent_link,
939
+ "little_ghost_conversation_id" => conversation_id
940
+ }
941
+ end
942
+
943
+ def commit_metadata(conversation_id, commit_id, message_count)
944
+ {
945
+ "little_ghost_kind" => "subagent_commit",
946
+ "little_ghost_parent_link" => @parent_link,
947
+ "little_ghost_conversation_id" => conversation_id,
948
+ "little_ghost_commit_id" => commit_id,
949
+ "little_ghost_message_count" => message_count
950
+ }
951
+ end
952
+
953
+ def validate_session_metadata!(session, expected)
954
+ actual = session.metadata
955
+ return if expected.all? { |key, value| actual[key] == value || actual[key.to_sym] == value }
956
+
957
+ raise ProtocolError, "Subagent session metadata does not match its parent conversation"
958
+ end
959
+
960
+ def normalize_registry_record(subagent_id, record)
961
+ return unless subagent_id.is_a?(String)
962
+ return unless AgentPath.immediate_child?(subagent_id, @parent_agent_path)
963
+ return unless record.is_a?(Hash)
964
+
965
+ conversation_id = record["conversation_id"] || record[:conversation_id]
966
+ kind = record["kind"] || record[:kind]
967
+ latest_turn = record["latest_turn"] || record[:latest_turn]
968
+ updated_at = record["updated_at"] || record[:updated_at]
969
+ message_count = record["message_count"] || record[:message_count]
970
+ commit_id = record["commit_id"] || record[:commit_id]
971
+ commit_slot = record["commit_slot"] || record[:commit_slot]
972
+ return unless conversation_id.is_a?(String) && conversation_id.match?(UUID_PATTERN)
973
+ return unless commit_id.is_a?(String) && commit_id.match?(UUID_PATTERN)
974
+ return unless kind.is_a?(String) && latest_turn.is_a?(Integer) && latest_turn.positive?
975
+ return unless message_count.is_a?(Integer) && message_count.positive?
976
+ return unless [0, 1].include?(commit_slot)
977
+
978
+ Time.iso8601(updated_at)
979
+ {
980
+ subagent_id:,
981
+ conversation_id:,
982
+ kind:,
983
+ latest_turn:,
984
+ updated_at:,
985
+ message_count:,
986
+ commit_id:,
987
+ commit_slot:
988
+ }
989
+ rescue ArgumentError, TypeError
990
+ nil
991
+ end
992
+
993
+ def mutable_copy(value)
994
+ case value
995
+ when Hash
996
+ value.to_h { |key, child| [mutable_copy(key), mutable_copy(child)] }
997
+ when Array
998
+ value.map { |child| mutable_copy(child) }
999
+ when String
1000
+ value.dup
1001
+ else
1002
+ value
1003
+ end
1004
+ end
1005
+
1006
+ def encode_cursor(identity)
1007
+ Base64.urlsafe_encode64(
1008
+ JSON.generate([identity.updated_at.to_s, identity.subagent_id]),
1009
+ padding: false
1010
+ )
1011
+ end
1012
+
1013
+ def decode_cursor(cursor)
1014
+ raise ToolError, "Invalid subagent list cursor" if String(cursor).bytesize > CURSOR_MAX_BYTES
1015
+
1016
+ value = JSON.parse(Base64.urlsafe_decode64(String(cursor)))
1017
+ unless value.is_a?(Array) && value.length == 2 && value.all? { |part| part.is_a?(String) }
1018
+ raise ToolError, "Invalid subagent list cursor"
1019
+ end
1020
+
1021
+ value
1022
+ rescue ArgumentError, JSON::ParserError
1023
+ raise ToolError, "Invalid subagent list cursor"
1024
+ end
1025
+
1026
+ def reserve_identity(kind, task, task_name:)
1027
+ @mutex.synchronize do
1028
+ ensure_open!
1029
+ definition = @definitions[kind]
1030
+ raise ToolError, "Unknown subagent kind: #{kind}" unless definition
1031
+
1032
+ return [nil, identity_capacity_response] if @identity_slots >= @max_identities
1033
+
1034
+ rejection = reject_turn_locked(task)
1035
+ return [nil, rejection] if rejection
1036
+
1037
+ subagent_id = agent_path(task_name)
1038
+ @identity_slots += 1
1039
+ @turn_count += 1
1040
+ @reserved_agent_paths[subagent_id] = true
1041
+ [definition, subagent_id]
1042
+ end
1043
+ end
1044
+
1045
+ def agent_path(task_name)
1046
+ name = AgentPath.validate_name!(task_name)
1047
+ candidate = AgentPath.join(@parent_agent_path, name)
1048
+ if @identities.key?(candidate) || @reserved_agent_paths.key?(candidate)
1049
+ raise ToolError, "Agent path #{candidate.inspect} already exists; choose a different task_name."
1050
+ end
1051
+ candidate
1052
+ rescue ArgumentError => error
1053
+ raise ToolError, error.message
1054
+ end
1055
+
1056
+ def release_identity_reservation(subagent_id)
1057
+ @mutex.synchronize do
1058
+ @reserved_agent_paths.delete(subagent_id)
1059
+ @identity_slots -= 1
1060
+ @turn_count -= 1
1061
+ end
1062
+ end
1063
+
1064
+ def enqueue(
1065
+ identity,
1066
+ message,
1067
+ event:,
1068
+ enforce_limits: false,
1069
+ count_turn: true,
1070
+ parent_operation_id: nil,
1071
+ context: nil
1072
+ )
1073
+ turn = nil
1074
+ queued_snapshot = nil
1075
+ @mutex.synchronize do
1076
+ ensure_open!
1077
+ if enforce_limits
1078
+ if %w[failed cancelled].include?(identity.status)
1079
+ raise ToolError,
1080
+ "Subagent #{identity.subagent_id.inspect} is #{identity.status}; spawn a new identity."
1081
+ end
1082
+ rejection = reject_turn_locked(message, identity: identity)
1083
+ return rejection if rejection
1084
+ end
1085
+
1086
+ turn = Turn.new(
1087
+ number: identity.next_turn,
1088
+ message: message,
1089
+ completion: Completion.new,
1090
+ operation_id: SecureRandom.uuid,
1091
+ parent_operation_id:,
1092
+ interrupts: [],
1093
+ interruption_metadata: context&.interruption_metadata,
1094
+ interruption_ids: context&.interruption_ids || []
1095
+ )
1096
+ identity.next_turn += 1
1097
+ @turn_count += 1 if count_turn
1098
+ identity.queue << turn
1099
+ identity.status = "queued" unless identity.status == "running"
1100
+ emit(event, identity, turn:)
1101
+ queued_snapshot = snapshot(identity)
1102
+ unless identity.worker&.alive?
1103
+ execution_state = ExecutionState.capture
1104
+ identity.worker = Thread.new do
1105
+ ExecutionState.with(execution_state) { run_identity(identity) }
1106
+ end
1107
+ end
1108
+ @condition.broadcast
1109
+ end
1110
+ [turn, queued_snapshot]
1111
+ end
1112
+
1113
+ def run_identity(identity)
1114
+ loop do
1115
+ turn = @mutex.synchronize do
1116
+ if @closed
1117
+ cancel_queued_turns(identity)
1118
+ identity.worker = nil
1119
+ @condition.broadcast
1120
+ return
1121
+ end
1122
+
1123
+ value = identity.queue.shift
1124
+ unless value
1125
+ identity.status = "idle"
1126
+ identity.worker = nil
1127
+ @condition.broadcast
1128
+ return
1129
+ end
1130
+ identity.current_turn = value.number
1131
+ identity.current = value
1132
+ identity.status = "queued"
1133
+ value
1134
+ end
1135
+
1136
+ failed = false
1137
+ cancelled = false
1138
+ ran = begin
1139
+ @capacity.synchronize(@cancellation_token, deadline: @deadline) do
1140
+ should_run = @mutex.synchronize do
1141
+ unless @closed
1142
+ identity.status = "running"
1143
+ identity.progress_message = nil
1144
+ identity.progress_sequence += 1
1145
+ emit("turn_started", identity, turn:)
1146
+ @condition.broadcast
1147
+ true
1148
+ end
1149
+ end
1150
+ next unless should_run
1151
+
1152
+ begin
1153
+ options = {cancellation_token: @cancellation_token}
1154
+ if identity.agent.is_a?(Agent)
1155
+ options[:deadline] = @deadline
1156
+ options[:parent_operation_id] = turn.operation_id
1157
+ options[:history] = identity.history
1158
+ options[:context] = identity.state
1159
+ options[:conversation_id] = identity.conversation_id
1160
+ options[:interruption_metadata] = turn.interruption_metadata
1161
+ options[:interruption_ids] = turn.interruption_ids
1162
+ end
1163
+ result = if identity.agent.is_a?(Agent)
1164
+ run_agent_turn(identity, turn, options)
1165
+ else
1166
+ identity.agent.call(turn.message, **options)
1167
+ end
1168
+ finish_turn(identity, turn, result)
1169
+ rescue CancelledError
1170
+ cancelled = true
1171
+ cancel_unrun_turn(identity, turn)
1172
+ rescue LittleGhost::CleanupError => error
1173
+ failed = true
1174
+ fail_turn(identity, turn, error, propagate: true)
1175
+ rescue => error
1176
+ failed = true
1177
+ fail_turn(identity, turn, error)
1178
+ end
1179
+ end
1180
+ rescue CancelledError
1181
+ cancelled = true
1182
+ cancel_unrun_turn(identity, turn)
1183
+ false
1184
+ rescue DeadlineExceededError => error
1185
+ failed = true
1186
+ fail_turn(identity, turn, error)
1187
+ false
1188
+ end
1189
+ cancel_unrun_turn(identity, turn) unless ran || failed || cancelled
1190
+ return if failed || cancelled
1191
+ end
1192
+ ensure
1193
+ @mutex.synchronize do
1194
+ identity.worker = nil if identity.worker == Thread.current
1195
+ @condition.broadcast
1196
+ end
1197
+ end
1198
+
1199
+ def run_agent_turn(identity, turn, options)
1200
+ result = nil
1201
+ identity.agent.stream(turn.message, **options).each do |event|
1202
+ capture_activity(identity, turn, event)
1203
+ result = event.data[:result] if event.type == :invocation_stop
1204
+ end
1205
+ result
1206
+ end
1207
+
1208
+ def capture_activity(identity, turn, event)
1209
+ return unless %i[tool_start tool_stop message_stop invocation_stop].include?(event.type)
1210
+
1211
+ message = progress_message(event)
1212
+ tool_use = event.data[:tool_use]
1213
+ @mutex.synchronize do
1214
+ return unless identity.current.equal?(turn)
1215
+
1216
+ identity.progress_message = message unless message.to_s.empty?
1217
+ identity.progress_sequence += 1
1218
+ @condition.broadcast
1219
+ if tool_use
1220
+ event_name = (event.type == :tool_start) ? "tool_started" : "tool_finished"
1221
+ emit(event_name, identity, turn:, tool_call_id: tool_use.id, tool_name: tool_use.name)
1222
+ else
1223
+ emit("activity", identity, turn:)
1224
+ end
1225
+ end
1226
+ end
1227
+
1228
+ def progress_message(event)
1229
+ return unless event.type == :message_stop
1230
+
1231
+ response = event.data[:response]
1232
+ return unless response&.stop_reason == :tool_use
1233
+ return unless response.message.role == :assistant
1234
+ return if response.message.content.grep(Content::ToolUse).empty?
1235
+
1236
+ normalize_progress(response.message)
1237
+ end
1238
+
1239
+ def normalize_progress(message)
1240
+ normalized = +""
1241
+ pending_space = false
1242
+ source_chars = 0
1243
+ message.content.each do |block|
1244
+ next unless block.is_a?(Content::Text)
1245
+
1246
+ block.text.each_char do |character|
1247
+ return normalized if source_chars >= MAX_PROGRESS_SOURCE_CHARS
1248
+
1249
+ source_chars += 1
1250
+ if character.match?(PROGRESS_SEPARATOR)
1251
+ pending_space = !normalized.empty?
1252
+ next
1253
+ end
1254
+
1255
+ normalized << " " if pending_space
1256
+ return normalized if normalized.length >= MAX_PROGRESS_CHARS
1257
+
1258
+ pending_space = false
1259
+ normalized << character
1260
+ return normalized if normalized.length >= MAX_PROGRESS_CHARS
1261
+ end
1262
+ end
1263
+ normalized
1264
+ end
1265
+
1266
+ def finish_turn(identity, turn, result)
1267
+ structured = result.is_a?(RunResult) && result.structured?
1268
+ response = if structured
1269
+ result.structured_result.value
1270
+ elsif result.respond_to?(:text)
1271
+ result.text.to_s
1272
+ else
1273
+ result.to_s
1274
+ end
1275
+ serialized_response = response.is_a?(String) ? response : JSON.generate(response)
1276
+ if structured && serialized_response.length > @max_response_chars
1277
+ raise StructuredResultError.new(
1278
+ "The structured subagent result exceeds the response limit",
1279
+ schema_name: result.structured_result.schema_name,
1280
+ validation_errors: ["result exceeds #{@max_response_chars} characters"]
1281
+ )
1282
+ end
1283
+ truncated = serialized_response.length > @max_response_chars
1284
+ response = serialized_response[0, @max_response_chars] if truncated
1285
+ persisted_response = response.is_a?(String) ? response : serialized_response
1286
+
1287
+ interrupt_exchanges = @mutex.synchronize do
1288
+ while identity.durable && turn.interrupts.any? { |exchange| !exchange.complete } && !@closed
1289
+ @condition.wait(@mutex, CANCELLATION_POLL_INTERVAL)
1290
+ end
1291
+ if @closed
1292
+ turn.completion.resolve(cancelled_turn(identity, turn))
1293
+ next nil
1294
+ end
1295
+
1296
+ identity.status = "persisting" if identity.durable
1297
+ turn.interrupts.select(&:complete).map { |exchange| [exchange.message, exchange.response] }
1298
+ end
1299
+ return unless interrupt_exchanges
1300
+
1301
+ retain_agent_conversation(identity, turn, result, persisted_response, interrupt_exchanges)
1302
+
1303
+ @mutex.synchronize do
1304
+ identity.latest_turn = turn.number
1305
+ identity.latest_response = response
1306
+ identity.latest_response_turn = turn.number
1307
+ identity.latest_response_truncated = truncated
1308
+ identity.latest_error = nil
1309
+ identity.progress_message = nil
1310
+ identity.current_turn = nil
1311
+ identity.current = nil
1312
+ value = {
1313
+ status: "finished",
1314
+ subagent_id: identity.subagent_id,
1315
+ kind: identity.definition.kind,
1316
+ turn: turn.number,
1317
+ response: response
1318
+ }
1319
+ value[:response_truncated] = true if truncated
1320
+ turn.completion.resolve(value)
1321
+ emit("turn_finished", identity, turn:)
1322
+ @condition.broadcast
1323
+ end
1324
+ end
1325
+
1326
+ def retain_agent_conversation(identity, turn, result, persisted_response, interrupt_exchanges)
1327
+ if identity.durable
1328
+ state = result.is_a?(RunResult) ? result.state : identity.state
1329
+ messages = [Message.new(role: :user, content: turn.message)]
1330
+ interrupt_exchanges.each do |message, response|
1331
+ messages << Message.new(role: :user, content: message)
1332
+ messages << Message.new(role: :assistant, content: response)
1333
+ end
1334
+ messages << Message.new(role: :assistant, content: persisted_response)
1335
+ identity.session.append(messages:, state:)
1336
+ message_count = identity.session.history.length
1337
+ persist_registry(identity, message_count:, state:)
1338
+ identity.committed_count = message_count
1339
+ project_conversation(identity, turn, messages)
1340
+ end
1341
+ if identity.agent.is_a?(Agent) && result.is_a?(RunResult)
1342
+ identity.history = result.messages.reject { |message| message.role == :system }.freeze
1343
+ identity.state = result.state
1344
+ end
1345
+ end
1346
+
1347
+ def project_conversation(identity, turn, messages)
1348
+ identity.session.project_conversation(
1349
+ messages:,
1350
+ metadata: {
1351
+ "little_ghost_parent_link" => @parent_link,
1352
+ "little_ghost_conversation_id" => identity.conversation_id,
1353
+ "little_ghost_subagent_id" => identity.subagent_id,
1354
+ "little_ghost_kind" => identity.definition.kind,
1355
+ "little_ghost_turn" => turn.number
1356
+ }
1357
+ )
1358
+ rescue => error
1359
+ warn_failure("projection", identity.subagent_id, error)
1360
+ end
1361
+
1362
+ def fail_turn(identity, turn, error, propagate: false)
1363
+ @mutex.synchronize do
1364
+ return if @closed && identity.status != "persisting"
1365
+
1366
+ warn_failure("turn", identity.subagent_id, error)
1367
+ identity.latest_turn = turn.number
1368
+ identity.latest_error = "Subagent turn failed."
1369
+ identity.progress_message = nil
1370
+ identity.current_turn = nil
1371
+ identity.current = nil
1372
+ identity.status = "failed"
1373
+ if propagate
1374
+ turn.completion.reject(error)
1375
+ else
1376
+ turn.completion.resolve(
1377
+ status: "failed",
1378
+ subagent_id: identity.subagent_id,
1379
+ kind: identity.definition.kind,
1380
+ turn: turn.number,
1381
+ error: identity.latest_error
1382
+ )
1383
+ end
1384
+ emit("turn_failed", identity, turn:, error_type: error.class.name)
1385
+ fail_queued_turns(identity)
1386
+ @condition.broadcast
1387
+ end
1388
+ end
1389
+
1390
+ def cancel_unrun_turn(identity, turn)
1391
+ @mutex.synchronize do
1392
+ newly_cancelled = identity.status != "cancelled"
1393
+ turn.completion.resolve(cancelled_turn(identity, turn))
1394
+ identity.latest_turn = turn.number
1395
+ identity.progress_message = nil
1396
+ identity.current_turn = nil
1397
+ identity.current = nil
1398
+ identity.status = "cancelled"
1399
+ cancel_queued_turns(identity)
1400
+ emit("cancelled", identity, turn:) if newly_cancelled
1401
+ @condition.broadcast
1402
+ end
1403
+ end
1404
+
1405
+ def cancelled_turn(identity, turn)
1406
+ {
1407
+ status: "cancelled",
1408
+ subagent_id: identity.subagent_id,
1409
+ kind: identity.definition.kind,
1410
+ turn: turn.number,
1411
+ error: "Subagent turn was cancelled."
1412
+ }
1413
+ end
1414
+
1415
+ def cancel_queued_turns(identity)
1416
+ identity.queue.each do |turn|
1417
+ turn.completion.resolve(cancelled_turn(identity, turn))
1418
+ emit("cancelled", identity, turn:)
1419
+ end
1420
+ identity.queue.clear
1421
+ end
1422
+
1423
+ def fail_queued_turns(identity)
1424
+ identity.queue.each do |turn|
1425
+ turn.completion.resolve(
1426
+ status: "failed",
1427
+ subagent_id: identity.subagent_id,
1428
+ kind: identity.definition.kind,
1429
+ turn: turn.number,
1430
+ error: "A previous turn failed; spawn a new identity."
1431
+ )
1432
+ emit("turn_failed", identity, turn:)
1433
+ end
1434
+ identity.queue.clear
1435
+ end
1436
+
1437
+ def reject_turn_locked(message, identity: nil)
1438
+ unless message.is_a?(String)
1439
+ return {status: "invalid_request", message: "Subagent messages must be strings."}
1440
+ end
1441
+ if message.length > @max_message_chars
1442
+ return {
1443
+ status: "invalid_request",
1444
+ message: "Subagent messages cannot exceed #{@max_message_chars} characters."
1445
+ }
1446
+ end
1447
+ if @turn_count >= @max_turns
1448
+ return {
1449
+ status: "capacity_reached",
1450
+ limit: @max_turns,
1451
+ message: "This run has reached its subagent turn limit."
1452
+ }
1453
+ end
1454
+ if identity && identity.queue.length >= @max_queued_turns_per_identity
1455
+ return {
1456
+ status: "capacity_reached",
1457
+ limit: @max_queued_turns_per_identity,
1458
+ message: "Subagent #{identity.subagent_id.inspect} has reached its queued turn limit.",
1459
+ subagent: snapshot(identity)
1460
+ }
1461
+ end
1462
+ nil
1463
+ end
1464
+
1465
+ def identity_capacity_response
1466
+ {
1467
+ status: "capacity_reached",
1468
+ limit: @max_identities,
1469
+ message: "This run has reached its subagent identity limit."
1470
+ }
1471
+ end
1472
+
1473
+ def selected_identities(subagent_ids)
1474
+ if subagent_ids.nil?
1475
+ return @identities.values.reject { |identity| identity.resumed && identity.agent.nil? }
1476
+ end
1477
+ raise ToolError, "subagent_ids must be unique" if subagent_ids.uniq.length != subagent_ids.length
1478
+
1479
+ subagent_ids.map do |subagent_id|
1480
+ identity = fetch_identity!(subagent_id)
1481
+ if identity.resumed && identity.agent.nil?
1482
+ raise ToolError, "Subagent #{subagent_id.inspect} is not active in this invocation."
1483
+ end
1484
+ identity
1485
+ end
1486
+ end
1487
+
1488
+ def fetch_identity!(subagent_id)
1489
+ @identities.fetch(subagent_id) { raise ToolError, "Unknown subagent id: #{subagent_id}" }
1490
+ end
1491
+
1492
+ def snapshot(identity, include_response: false, include_progress: false)
1493
+ value = {
1494
+ subagent_id: identity.subagent_id,
1495
+ conversation_id: identity.conversation_id,
1496
+ kind: identity.definition.kind,
1497
+ status: identity.status,
1498
+ current_turn: identity.current_turn,
1499
+ latest_turn: identity.latest_turn,
1500
+ queued_turns: identity.queue.length
1501
+ }
1502
+ value[:resumed] = true if identity.resumed
1503
+ if include_response && identity.latest_response
1504
+ if identity.status == "idle" && identity.latest_response_turn == identity.latest_turn
1505
+ value[:response] = identity.latest_response
1506
+ value[:response_turn] = identity.latest_response_turn
1507
+ value[:response_truncated] = true if identity.latest_response_truncated
1508
+ else
1509
+ value[:previous_response] = identity.latest_response
1510
+ value[:previous_response_turn] = identity.latest_response_turn
1511
+ value[:previous_response_truncated] = true if identity.latest_response_truncated
1512
+ end
1513
+ end
1514
+ if include_progress && identity.progress_sequence.positive? && %w[queued running].include?(identity.status)
1515
+ value[:progress] = {sequence: identity.progress_sequence}
1516
+ value[:progress][:message] = identity.progress_message if identity.progress_message
1517
+ end
1518
+ value[:error] = identity.latest_error if identity.latest_error
1519
+ value
1520
+ end
1521
+
1522
+ def finished?(identity)
1523
+ %w[idle failed cancelled].include?(identity.status)
1524
+ end
1525
+
1526
+ def emit(event, identity, turn: nil, **attributes)
1527
+ return unless @observer
1528
+
1529
+ value = {
1530
+ event: event,
1531
+ subagent_id: identity.subagent_id,
1532
+ conversation_id: identity.conversation_id,
1533
+ resumed: identity.resumed,
1534
+ kind: identity.definition.kind,
1535
+ status: identity.status
1536
+ }
1537
+ if turn
1538
+ value[:turn] = turn.respond_to?(:number) ? turn.number : turn
1539
+ value[:operation_id] = turn.operation_id if turn.respond_to?(:operation_id) && turn.operation_id
1540
+ if turn.respond_to?(:parent_operation_id) && turn.parent_operation_id
1541
+ value[:parent_operation_id] = turn.parent_operation_id
1542
+ end
1543
+ end
1544
+ value.merge!(attributes)
1545
+ @observer.call(value.freeze)
1546
+ rescue
1547
+ nil
1548
+ end
1549
+
1550
+ def observe_delegated_activity(identity)
1551
+ activity = identity.agent.respond_to?(:delegation_activity) && identity.agent.delegation_activity
1552
+ return unless activity
1553
+
1554
+ activity.subscribe { record_delegated_activity(identity) }
1555
+ end
1556
+
1557
+ def record_delegated_activity(identity)
1558
+ turn = @mutex.synchronize do
1559
+ next unless identity.status == "running" && identity.current
1560
+
1561
+ identity.progress_sequence += 1
1562
+ @condition.broadcast
1563
+ identity.current
1564
+ end
1565
+ emit("activity", identity, turn:) if turn
1566
+ end
1567
+
1568
+ def emit_factory_failure(definition, subagent_id, error, parent_operation_id: nil)
1569
+ return unless @observer
1570
+
1571
+ @observer.call({
1572
+ event: "factory_failed",
1573
+ subagent_id:,
1574
+ kind: definition.kind,
1575
+ status: "failed",
1576
+ error_type: error.class.name,
1577
+ parent_operation_id:
1578
+ }.compact.freeze)
1579
+ rescue
1580
+ nil
1581
+ end
1582
+
1583
+ def ensure_open!
1584
+ raise Error, "Subagent manager is closed" if @closed
1585
+ end
1586
+
1587
+ def validate_mode(mode)
1588
+ raise ToolError, "mode must be 'sync' or 'async'" unless %w[sync async].include?(mode)
1589
+ end
1590
+
1591
+ def validate_limit(name, value)
1592
+ raise ArgumentError, "#{name} must be at least 1" unless value.is_a?(Integer) && value >= 1
1593
+ end
1594
+
1595
+ def validate_timeout(name, value)
1596
+ raise ArgumentError, "#{name} cannot be negative" unless value.is_a?(Numeric) && value >= 0
1597
+ end
1598
+
1599
+ def monotonic_time
1600
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
1601
+ end
1602
+
1603
+ def warn_failure(stage, subagent_id, error)
1604
+ Events.warn(
1605
+ "little_ghost.subagent.operation_failed",
1606
+ stage:,
1607
+ subagent_id:,
1608
+ error_type: error.class.name
1609
+ )
1610
+ rescue
1611
+ nil
1612
+ end
1613
+ end
1614
+ end
1615
+ end