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.
- checksums.yaml +7 -0
- data/LICENSE.txt +22 -0
- data/README.md +122 -0
- data/docs/guides/Core Concepts.md +203 -0
- data/docs/guides/Getting Started.md +187 -0
- data/lib/little_ghost/ag_ui/adapter.rb +194 -0
- data/lib/little_ghost/ag_ui.rb +5 -0
- data/lib/little_ghost/agent/context_management.rb +285 -0
- data/lib/little_ghost/agent/delegation.rb +128 -0
- data/lib/little_ghost/agent/skills.rb +96 -0
- data/lib/little_ghost/agent/tool_loop.rb +239 -0
- data/lib/little_ghost/agent.rb +2111 -0
- data/lib/little_ghost/agent_builder.rb +191 -0
- data/lib/little_ghost/agent_interruptions.rb +197 -0
- data/lib/little_ghost/configuration.rb +337 -0
- data/lib/little_ghost/content.rb +324 -0
- data/lib/little_ghost/default_model_registry.rb +71 -0
- data/lib/little_ghost/errors.rb +48 -0
- data/lib/little_ghost/events.rb +264 -0
- data/lib/little_ghost/execution_state.rb +58 -0
- data/lib/little_ghost/instrumentation.rb +475 -0
- data/lib/little_ghost/invocation.rb +285 -0
- data/lib/little_ghost/lookup.rb +37 -0
- data/lib/little_ghost/mcp/client.rb +396 -0
- data/lib/little_ghost/mcp.rb +5 -0
- data/lib/little_ghost/message.rb +75 -0
- data/lib/little_ghost/model.rb +88 -0
- data/lib/little_ghost/model_capabilities.rb +126 -0
- data/lib/little_ghost/model_registry.rb +173 -0
- data/lib/little_ghost/model_request.rb +107 -0
- data/lib/little_ghost/model_response.rb +48 -0
- data/lib/little_ghost/path_set.rb +32 -0
- data/lib/little_ghost/prompt_resolver.rb +251 -0
- data/lib/little_ghost/providers/bedrock.rb +506 -0
- data/lib/little_ghost/providers/http_transport.rb +149 -0
- data/lib/little_ghost/providers/open_router.rb +171 -0
- data/lib/little_ghost/providers/openai.rb +27 -0
- data/lib/little_ghost/providers/openai_compatible.rb +745 -0
- data/lib/little_ghost/providers/sse_parser.rb +35 -0
- data/lib/little_ghost/run.rb +607 -0
- data/lib/little_ghost/run_context.rb +129 -0
- data/lib/little_ghost/run_result.rb +111 -0
- data/lib/little_ghost/runtime/hook.rb +31 -0
- data/lib/little_ghost/runtime.rb +392 -0
- data/lib/little_ghost/sandbox.rb +138 -0
- data/lib/little_ghost/session.rb +229 -0
- data/lib/little_ghost/session_store.rb +96 -0
- data/lib/little_ghost/session_stores/agent_core_memory.rb +1086 -0
- data/lib/little_ghost/session_stores/memory.rb +86 -0
- data/lib/little_ghost/skills/catalog.rb +283 -0
- data/lib/little_ghost/skills/skill.rb +60 -0
- data/lib/little_ghost/skills.rb +4 -0
- data/lib/little_ghost/stream_event.rb +49 -0
- data/lib/little_ghost/structured_output.rb +126 -0
- data/lib/little_ghost/subagents/agent_path.rb +63 -0
- data/lib/little_ghost/subagents/definition.rb +42 -0
- data/lib/little_ghost/subagents/manager.rb +1615 -0
- data/lib/little_ghost/support/callbacks.rb +151 -0
- data/lib/little_ghost/support/cancellation_token.rb +86 -0
- data/lib/little_ghost/support/class_attributes.rb +40 -0
- data/lib/little_ghost/support/content_capture.rb +150 -0
- data/lib/little_ghost/support/executor.rb +75 -0
- data/lib/little_ghost/support/interruptible_stream.rb +103 -0
- data/lib/little_ghost/support/loader.rb +263 -0
- data/lib/little_ghost/support/output_truncation.rb +71 -0
- data/lib/little_ghost/support/redactor.rb +66 -0
- data/lib/little_ghost/support.rb +34 -0
- data/lib/little_ghost/tool.rb +448 -0
- data/lib/little_ghost/tool_execution.rb +59 -0
- data/lib/little_ghost/tool_registry.rb +156 -0
- data/lib/little_ghost/tools/filesystem.rb +119 -0
- data/lib/little_ghost/tools/shell.rb +45 -0
- data/lib/little_ghost/tools/write_todos.rb +91 -0
- data/lib/little_ghost/tools.rb +6 -0
- data/lib/little_ghost/tracing/open_telemetry.rb +517 -0
- data/lib/little_ghost/unrestricted_sandbox.rb +306 -0
- data/lib/little_ghost/usage.rb +47 -0
- data/lib/little_ghost/version.rb +6 -0
- data/lib/little_ghost/workflow.rb +351 -0
- data/lib/little_ghost/workspace.rb +31 -0
- data/lib/little_ghost.rb +120 -0
- 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
|