little_ghost 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +72 -74
- data/docs/guides/assemblies.md +286 -0
- data/docs/guides/core_concepts.md +159 -135
- data/docs/guides/getting_started.md +114 -83
- data/docs/guides/production.md +187 -0
- data/docs/guides/prompt_views.md +132 -0
- data/lib/little_ghost/ag_ui/adapter.rb +3 -3
- data/lib/little_ghost/agent/delegation.rb +35 -8
- data/lib/little_ghost/agent/tool_loop.rb +2 -1
- data/lib/little_ghost/agent.rb +280 -326
- data/lib/little_ghost/agent_builder.rb +20 -4
- data/lib/little_ghost/agent_factory.rb +3 -0
- data/lib/little_ghost/{agent_interruptions.rb → agent_interjections.rb} +12 -12
- data/lib/little_ghost/assembly.rb +345 -0
- data/lib/little_ghost/assembly_builder.rb +497 -0
- data/lib/little_ghost/assembly_execution.rb +535 -0
- data/lib/little_ghost/configuration.rb +263 -39
- data/lib/little_ghost/content.rb +5 -5
- data/lib/little_ghost/data_map.rb +209 -0
- data/lib/little_ghost/errors.rb +10 -2
- data/lib/little_ghost/execution.rb +206 -0
- data/lib/little_ghost/graph.rb +930 -0
- data/lib/little_ghost/message.rb +4 -4
- data/lib/little_ghost/model_resolver.rb +2 -2
- data/lib/little_ghost/prompt_resolver.rb +2 -0
- data/lib/little_ghost/run.rb +190 -64
- data/lib/little_ghost/run_context.rb +33 -20
- data/lib/little_ghost/run_result.rb +22 -11
- data/lib/little_ghost/runtime/hook.rb +9 -4
- data/lib/little_ghost/runtime.rb +134 -36
- data/lib/little_ghost/sandbox.rb +1 -1
- data/lib/little_ghost/session.rb +12 -23
- data/lib/little_ghost/session_store.rb +9 -5
- data/lib/little_ghost/session_stores/agent_core_memory.rb +64 -56
- data/lib/little_ghost/session_stores/filesystem.rb +261 -0
- data/lib/little_ghost/session_stores/memory.rb +7 -0
- data/lib/little_ghost/subagents/manager.rb +42 -42
- data/lib/little_ghost/support/executor.rb +14 -2
- data/lib/little_ghost/support/loader.rb +2 -2
- data/lib/little_ghost/support.rb +15 -3
- data/lib/little_ghost/swarm.rb +439 -0
- data/lib/little_ghost/tool.rb +88 -20
- data/lib/little_ghost/tools/write_todos.rb +6 -1
- data/lib/little_ghost/tracing/open_telemetry.rb +14 -3
- data/lib/little_ghost/unrestricted_sandbox.rb +1 -1
- data/lib/little_ghost/version.rb +1 -1
- data/lib/little_ghost/workflow.rb +224 -90
- data/lib/little_ghost.rb +36 -25
- metadata +17 -5
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
require_relative "../data_map"
|
|
8
|
+
require_relative "../session_store"
|
|
9
|
+
|
|
10
|
+
module LittleGhost
|
|
11
|
+
module SessionStores
|
|
12
|
+
# Filesystem preserves LittleGhost sessions across process restarts in an
|
|
13
|
+
# application-controlled directory. Use it for durable local development,
|
|
14
|
+
# a single-host service, or processes that share a suitable filesystem.
|
|
15
|
+
#
|
|
16
|
+
# store = LittleGhost::SessionStores::Filesystem.new(
|
|
17
|
+
# root: "/var/lib/customer_support/sessions"
|
|
18
|
+
# )
|
|
19
|
+
#
|
|
20
|
+
# Configure the resulting store through Configuration#session_store, or
|
|
21
|
+
# pass it directly when opening a Session. Calls for the same session wait
|
|
22
|
+
# for one writer, including when separate Ruby processes share the root.
|
|
23
|
+
#
|
|
24
|
+
# **Warning:** The root contains readable session data and is not encrypted.
|
|
25
|
+
# Its complete path must be application-controlled: anyone able to read it
|
|
26
|
+
# can read session data, and anyone able to replace it can alter sessions.
|
|
27
|
+
#
|
|
28
|
+
# Session data is stored as ordinary JSON with canonical String keys. A
|
|
29
|
+
# value outside that boundary raises ProtocolError without replacing the
|
|
30
|
+
# previous snapshot. Shared roots require filesystem support for file
|
|
31
|
+
# locking and atomic rename.
|
|
32
|
+
class Filesystem < SessionStore
|
|
33
|
+
FORMAT_VERSION = 1 # :nodoc:
|
|
34
|
+
|
|
35
|
+
# Creates a store rooted at +root+ and creates the directory when needed.
|
|
36
|
+
#
|
|
37
|
+
# +root+ must be a private, non-symlinked directory. The application owns
|
|
38
|
+
# the complete path and must not let an untrusted request choose it.
|
|
39
|
+
# Raises ArgumentError when the root does not meet those requirements.
|
|
40
|
+
def initialize(root:)
|
|
41
|
+
super()
|
|
42
|
+
@root = File.expand_path(String(root))
|
|
43
|
+
raise ArgumentError, "root must not be empty" if root.to_s.empty?
|
|
44
|
+
|
|
45
|
+
FileUtils.mkdir_p(@root, mode: 0o700)
|
|
46
|
+
validate_root!
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Returns the stored snapshot for +id+, or +nil+ before the first write.
|
|
50
|
+
#
|
|
51
|
+
# +actor_id+ must match the actor that created an existing session.
|
|
52
|
+
# Raises Error for an actor mismatch and ProtocolError for an invalid or
|
|
53
|
+
# unsafe persisted snapshot.
|
|
54
|
+
def load(id, actor_id: nil)
|
|
55
|
+
with_lock(id) do
|
|
56
|
+
snapshot = read_snapshot(id)
|
|
57
|
+
validate_actor!(snapshot, actor_id) if snapshot
|
|
58
|
+
snapshot
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Atomically appends sanitized +messages+ and returns the updated snapshot.
|
|
63
|
+
#
|
|
64
|
+
# +expected_count+ must match the stored history length. +state+ and
|
|
65
|
+
# +metadata+ must meet this store's JSON boundary. Raises ProtocolError
|
|
66
|
+
# when another writer changed the session or the snapshot cannot be read
|
|
67
|
+
# or written. Raises Error when +actor_id+ does not match the session.
|
|
68
|
+
def append(id, messages:, state:, metadata:, expected_count:, actor_id: nil)
|
|
69
|
+
messages = persistable_messages(messages)
|
|
70
|
+
state = canonical_map(state)
|
|
71
|
+
metadata = canonical_map(metadata)
|
|
72
|
+
with_lock(id) do
|
|
73
|
+
current = read_snapshot(id)
|
|
74
|
+
validate_actor!(current, actor_id) if current
|
|
75
|
+
current ||= empty_snapshot(actor_id)
|
|
76
|
+
unless current.fetch(:messages).length == expected_count
|
|
77
|
+
raise ProtocolError, "Session changed while it was being updated"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
snapshot = {
|
|
81
|
+
actor_digest: current.fetch(:actor_digest),
|
|
82
|
+
messages: [*current.fetch(:messages), *messages].freeze,
|
|
83
|
+
state:,
|
|
84
|
+
metadata:
|
|
85
|
+
}.freeze
|
|
86
|
+
write_snapshot(id, snapshot)
|
|
87
|
+
public_snapshot(snapshot)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Atomically replaces the complete persisted snapshot and returns it.
|
|
92
|
+
#
|
|
93
|
+
# +state+, +metadata+, and +actor_id+ follow the same requirements as
|
|
94
|
+
# #append.
|
|
95
|
+
def replace(id, messages:, state:, metadata:, actor_id: nil)
|
|
96
|
+
messages = persistable_messages(messages)
|
|
97
|
+
state = canonical_map(state)
|
|
98
|
+
metadata = canonical_map(metadata)
|
|
99
|
+
with_lock(id) do
|
|
100
|
+
current = read_snapshot(id)
|
|
101
|
+
validate_actor!(current, actor_id) if current
|
|
102
|
+
snapshot = {
|
|
103
|
+
actor_digest: current ? current.fetch(:actor_digest) : actor_digest(actor_id),
|
|
104
|
+
messages: messages.freeze,
|
|
105
|
+
state:,
|
|
106
|
+
metadata:
|
|
107
|
+
}.freeze
|
|
108
|
+
write_snapshot(id, snapshot)
|
|
109
|
+
public_snapshot(snapshot)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
def empty_snapshot(actor_id)
|
|
116
|
+
{
|
|
117
|
+
actor_digest: actor_digest(actor_id),
|
|
118
|
+
messages: [].freeze,
|
|
119
|
+
state: {}.freeze,
|
|
120
|
+
metadata: {}.freeze
|
|
121
|
+
}.freeze
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def public_snapshot(snapshot)
|
|
125
|
+
snapshot.slice(:messages, :state, :metadata)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def validate_actor!(snapshot, actor_id)
|
|
129
|
+
return if snapshot.fetch(:actor_digest) == actor_digest(actor_id)
|
|
130
|
+
|
|
131
|
+
raise Error, "Session actor does not match"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def with_lock(id)
|
|
135
|
+
path = lock_path(id)
|
|
136
|
+
validate_entry!(path) if File.exist?(path) || File.symlink?(path)
|
|
137
|
+
File.open(path, File::RDWR | File::CREAT, 0o600) do |lock|
|
|
138
|
+
lock.flock(File::LOCK_EX)
|
|
139
|
+
yield
|
|
140
|
+
ensure
|
|
141
|
+
lock.flock(File::LOCK_UN)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def read_snapshot(id)
|
|
146
|
+
path = snapshot_path(id)
|
|
147
|
+
return unless File.exist?(path) || File.symlink?(path)
|
|
148
|
+
|
|
149
|
+
validate_entry!(path)
|
|
150
|
+
|
|
151
|
+
document = JSON.parse(File.binread(path))
|
|
152
|
+
validate_document!(document)
|
|
153
|
+
snapshot = document.fetch("snapshot")
|
|
154
|
+
validate_snapshot!(snapshot)
|
|
155
|
+
{
|
|
156
|
+
actor_digest: document.fetch("actor_digest"),
|
|
157
|
+
messages: Array(snapshot.fetch("messages")).map { |message| Message.coerce(message) }.freeze,
|
|
158
|
+
state: snapshot.fetch("state"),
|
|
159
|
+
metadata: snapshot.fetch("metadata")
|
|
160
|
+
}.freeze
|
|
161
|
+
rescue JSON::ParserError, KeyError, TypeError, ArgumentError, NoMethodError => error
|
|
162
|
+
raise ProtocolError, "Filesystem session snapshot is invalid: #{error.class}"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def write_snapshot(id, snapshot)
|
|
166
|
+
serialized_snapshot = public_snapshot(snapshot).merge(
|
|
167
|
+
messages: snapshot.fetch(:messages).map(&:to_h)
|
|
168
|
+
)
|
|
169
|
+
document = {
|
|
170
|
+
"version" => FORMAT_VERSION,
|
|
171
|
+
"actor_digest" => snapshot.fetch(:actor_digest),
|
|
172
|
+
"snapshot" => serialized_snapshot
|
|
173
|
+
}
|
|
174
|
+
document["digest"] = Digest::SHA256.hexdigest(JSON.generate(document))
|
|
175
|
+
write_atomically(snapshot_path(id), JSON.generate(document))
|
|
176
|
+
rescue JSON::GeneratorError, TypeError => error
|
|
177
|
+
raise ProtocolError, "Filesystem session snapshot could not be serialized: #{error.class}"
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def validate_document!(document)
|
|
181
|
+
raise ArgumentError unless document.is_a?(Hash)
|
|
182
|
+
raise ArgumentError unless document.fetch("version") == FORMAT_VERSION
|
|
183
|
+
raise ArgumentError unless document.fetch("actor_digest").match?(/\A[0-9a-f]{64}\z/)
|
|
184
|
+
digest = document.fetch("digest")
|
|
185
|
+
raise ArgumentError unless digest.is_a?(String) && digest.match?(/\A[0-9a-f]{64}\z/)
|
|
186
|
+
|
|
187
|
+
expected = document.except("digest")
|
|
188
|
+
raise ArgumentError unless Digest::SHA256.hexdigest(JSON.generate(expected)) == digest
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def validate_snapshot!(snapshot)
|
|
192
|
+
raise ArgumentError unless snapshot.is_a?(Hash)
|
|
193
|
+
raise ArgumentError unless snapshot.fetch("messages").is_a?(Array)
|
|
194
|
+
DataMap.new(snapshot.fetch("state"))
|
|
195
|
+
DataMap.new(snapshot.fetch("metadata"))
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def canonical_map(value)
|
|
199
|
+
DataMap.new(value).to_h
|
|
200
|
+
rescue ArgumentError => error
|
|
201
|
+
raise ProtocolError, "Filesystem session data is invalid: #{error.message}"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def write_atomically(path, content)
|
|
205
|
+
validate_entry!(path) if File.exist?(path) || File.symlink?(path)
|
|
206
|
+
temporary = "#{path}.#{Process.pid}.#{SecureRandom.hex(8)}.tmp"
|
|
207
|
+
File.open(temporary, "wb", 0o600) do |file|
|
|
208
|
+
file.write(content)
|
|
209
|
+
file.flush
|
|
210
|
+
file.fsync
|
|
211
|
+
end
|
|
212
|
+
File.rename(temporary, path)
|
|
213
|
+
sync_root
|
|
214
|
+
ensure
|
|
215
|
+
File.delete(temporary) if temporary && File.exist?(temporary)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def sync_root
|
|
219
|
+
File.open(@root, File::RDONLY) { |directory| directory.fsync }
|
|
220
|
+
rescue SystemCallError
|
|
221
|
+
nil
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def validate_root!
|
|
225
|
+
stat = File.lstat(@root)
|
|
226
|
+
unless stat.directory? && !stat.symlink? && private_mode?(stat)
|
|
227
|
+
raise ArgumentError, "root must be a private, non-symlinked directory"
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def validate_entry!(path)
|
|
232
|
+
stat = File.lstat(path)
|
|
233
|
+
unless stat.file? && !stat.symlink? && private_mode?(stat)
|
|
234
|
+
raise ProtocolError, "Filesystem session entry is unsafe"
|
|
235
|
+
end
|
|
236
|
+
rescue Errno::ENOENT
|
|
237
|
+
nil
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def private_mode?(stat)
|
|
241
|
+
(stat.mode & 0o077).zero?
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def snapshot_path(id)
|
|
245
|
+
File.join(@root, "#{session_digest(id)}.json")
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def lock_path(id)
|
|
249
|
+
File.join(@root, "#{session_digest(id)}.lock")
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def session_digest(id)
|
|
253
|
+
Digest::SHA256.hexdigest("little_ghost/filesystem_session/v1\0#{String(id)}")
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def actor_digest(actor_id)
|
|
257
|
+
Digest::SHA256.hexdigest(JSON.generate([actor_id.nil?, actor_id&.to_s]))
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../data_map"
|
|
4
|
+
require_relative "../session_store"
|
|
5
|
+
|
|
3
6
|
module LittleGhost
|
|
4
7
|
# Ready-to-use persistence implementations for LittleGhost conversations.
|
|
5
8
|
module SessionStores
|
|
@@ -32,6 +35,8 @@ module LittleGhost
|
|
|
32
35
|
# stored conversation, then returns the updated snapshot.
|
|
33
36
|
def append(id, messages:, state:, metadata:, expected_count:, actor_id: nil)
|
|
34
37
|
messages = persistable_messages(messages)
|
|
38
|
+
state = DataMap.new(state).to_h
|
|
39
|
+
metadata = DataMap.new(metadata).to_h
|
|
35
40
|
@records_mutex.synchronize do
|
|
36
41
|
key = id.to_s
|
|
37
42
|
record = @records[key]
|
|
@@ -56,6 +61,8 @@ module LittleGhost
|
|
|
56
61
|
# Replaces the complete in-memory snapshot with sanitized +messages+.
|
|
57
62
|
def replace(id, messages:, state:, metadata:, actor_id: nil)
|
|
58
63
|
messages = persistable_messages(messages)
|
|
64
|
+
state = DataMap.new(state).to_h
|
|
65
|
+
metadata = DataMap.new(metadata).to_h
|
|
59
66
|
@records_mutex.synchronize do
|
|
60
67
|
key = id.to_s
|
|
61
68
|
validate_actor!(@records[key], actor_id)
|
|
@@ -21,11 +21,11 @@ module LittleGhost
|
|
|
21
21
|
# end
|
|
22
22
|
#
|
|
23
23
|
# LittleGhost then gives +CustomerSupportAgent+ tools to spawn, message, wait for,
|
|
24
|
-
#
|
|
24
|
+
# interject, and list research agents. The manager keeps each child identity
|
|
25
25
|
# stable across follow-up turns.
|
|
26
26
|
#
|
|
27
|
-
# Follow-up messages are FIFO turns and never
|
|
28
|
-
# #
|
|
27
|
+
# Follow-up messages are FIFO turns and never interject active work.
|
|
28
|
+
# #interject is the separate synchronous path for delivery at the next model
|
|
29
29
|
# boundary; delivery does not stop the child, and tool calls from that model
|
|
30
30
|
# response continue in the child run.
|
|
31
31
|
#
|
|
@@ -58,16 +58,16 @@ module LittleGhost
|
|
|
58
58
|
CURSOR_MAX_BYTES = 512 # :nodoc:
|
|
59
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
60
|
|
|
61
|
-
|
|
61
|
+
InterjectionExchange = Struct.new(:message, :response, :complete) # :nodoc:
|
|
62
62
|
Turn = Struct.new( # :nodoc:
|
|
63
63
|
:number,
|
|
64
64
|
:message,
|
|
65
65
|
:completion,
|
|
66
66
|
:operation_id,
|
|
67
67
|
:parent_operation_id,
|
|
68
|
-
:
|
|
69
|
-
:
|
|
70
|
-
:
|
|
68
|
+
:interjections,
|
|
69
|
+
:interjection_metadata,
|
|
70
|
+
:interjection_ids
|
|
71
71
|
)
|
|
72
72
|
Identity = Struct.new( # :nodoc:
|
|
73
73
|
:subagent_id,
|
|
@@ -368,7 +368,7 @@ module LittleGhost
|
|
|
368
368
|
# model response. The returned +response_disposition+ says whether that
|
|
369
369
|
# response also initiated tool calls; it does not imply the subagent has
|
|
370
370
|
# stopped.
|
|
371
|
-
def
|
|
371
|
+
def interject(subagent_id:, message:, cancellation_token: @cancellation_token, deadline: @deadline)
|
|
372
372
|
unless message.is_a?(String)
|
|
373
373
|
raise ToolError, "Subagent messages must be strings."
|
|
374
374
|
end
|
|
@@ -376,30 +376,30 @@ module LittleGhost
|
|
|
376
376
|
raise ToolError, "Subagent messages cannot exceed #{@max_message_chars} characters."
|
|
377
377
|
end
|
|
378
378
|
|
|
379
|
-
exchange =
|
|
379
|
+
exchange = InterjectionExchange.new(message:, complete: false)
|
|
380
380
|
identity, turn = @mutex.synchronize do
|
|
381
381
|
ensure_open!
|
|
382
382
|
value = fetch_identity!(subagent_id)
|
|
383
|
-
unless value.agent.respond_to?(:
|
|
384
|
-
raise ToolError, "Subagent #{subagent_id.inspect} does not support
|
|
383
|
+
unless value.agent.respond_to?(:interject)
|
|
384
|
+
raise ToolError, "Subagent #{subagent_id.inspect} does not support interjections."
|
|
385
385
|
end
|
|
386
386
|
unless value.status == "running"
|
|
387
387
|
raise ToolError, "Subagent #{subagent_id.inspect} is not currently running."
|
|
388
388
|
end
|
|
389
|
-
if value.current.
|
|
390
|
-
raise ToolError, "Subagent #{subagent_id.inspect} has reached its
|
|
389
|
+
if value.current.interjections.length >= @max_queued_turns_per_identity
|
|
390
|
+
raise ToolError, "Subagent #{subagent_id.inspect} has reached its interject limit."
|
|
391
391
|
end
|
|
392
|
-
|
|
393
|
-
if
|
|
394
|
-
raise ToolError, "Subagent
|
|
392
|
+
interjection_chars = value.current.interjections.sum { |pending| pending.message.length }
|
|
393
|
+
if interjection_chars + message.length > @max_message_chars
|
|
394
|
+
raise ToolError, "Subagent interjection messages cannot exceed #{@max_message_chars} total characters."
|
|
395
395
|
end
|
|
396
396
|
|
|
397
|
-
value.current.
|
|
397
|
+
value.current.interjections << exchange
|
|
398
398
|
[value, value.current]
|
|
399
399
|
end
|
|
400
400
|
|
|
401
|
-
|
|
402
|
-
identity.agent.
|
|
401
|
+
result = begin
|
|
402
|
+
identity.agent.interject(
|
|
403
403
|
message,
|
|
404
404
|
cancellation_token:,
|
|
405
405
|
deadline:,
|
|
@@ -407,16 +407,16 @@ module LittleGhost
|
|
|
407
407
|
)
|
|
408
408
|
rescue
|
|
409
409
|
@mutex.synchronize do
|
|
410
|
-
turn.
|
|
410
|
+
turn.interjections.delete(exchange)
|
|
411
411
|
@condition.broadcast
|
|
412
412
|
end
|
|
413
413
|
raise
|
|
414
414
|
end
|
|
415
|
-
response =
|
|
415
|
+
response = result.text
|
|
416
416
|
truncated = response.length > @max_response_chars
|
|
417
417
|
returned_response = truncated ? response[0, @max_response_chars] : response
|
|
418
418
|
@mutex.synchronize do
|
|
419
|
-
used_response_chars = turn.
|
|
419
|
+
used_response_chars = turn.interjections.sum do |pending|
|
|
420
420
|
pending.equal?(exchange) ? 0 : pending.response.to_s.length
|
|
421
421
|
end
|
|
422
422
|
remaining_response_chars = [@max_response_chars - used_response_chars, 0].max
|
|
@@ -428,17 +428,17 @@ module LittleGhost
|
|
|
428
428
|
snapshot(identity, include_response: true, include_progress: true)
|
|
429
429
|
end
|
|
430
430
|
value = {
|
|
431
|
-
status: "
|
|
431
|
+
status: "interjection_delivered",
|
|
432
432
|
subagent_id: identity.subagent_id,
|
|
433
433
|
kind: identity.definition.kind,
|
|
434
434
|
subagent:,
|
|
435
435
|
turn: turn.number,
|
|
436
436
|
response: returned_response,
|
|
437
|
-
response_disposition:
|
|
437
|
+
response_disposition: result.tool_calls? ? "text_with_tool_calls" : "text_only"
|
|
438
438
|
}
|
|
439
439
|
value[:response_truncated] = true if truncated
|
|
440
440
|
value
|
|
441
|
-
rescue
|
|
441
|
+
rescue AgentInterjectionError => error
|
|
442
442
|
raise ToolError, error.message
|
|
443
443
|
end
|
|
444
444
|
|
|
@@ -504,7 +504,7 @@ module LittleGhost
|
|
|
504
504
|
end
|
|
505
505
|
end
|
|
506
506
|
|
|
507
|
-
# Builds spawn, follow-up,
|
|
507
|
+
# Builds spawn, follow-up, interject, wait, and list tools bound to this
|
|
508
508
|
# manager. Closing the first tool closes the shared manager.
|
|
509
509
|
def tools
|
|
510
510
|
manager = self
|
|
@@ -559,8 +559,8 @@ module LittleGhost
|
|
|
559
559
|
description: <<~DESCRIPTION.strip,
|
|
560
560
|
Send a follow-up turn to an existing active or persisted subagent identity. Persisted conversations
|
|
561
561
|
are restored transparently before the follow-up. Messages are processed in order after the
|
|
562
|
-
current turn and never
|
|
563
|
-
finalization; use
|
|
562
|
+
current turn and never interject active work. Do not use this for status, steering, stopping, or
|
|
563
|
+
finalization; use interject_subagent for an active subagent. Mode controls delivery: sync waits for the
|
|
564
564
|
later turn's response, while async enqueues the turn and returns immediately.
|
|
565
565
|
DESCRIPTION
|
|
566
566
|
input_schema: {
|
|
@@ -586,7 +586,7 @@ module LittleGhost
|
|
|
586
586
|
)
|
|
587
587
|
end,
|
|
588
588
|
Tool.define(
|
|
589
|
-
name: "
|
|
589
|
+
name: "interject_subagent",
|
|
590
590
|
description: <<~DESCRIPTION.strip,
|
|
591
591
|
Interrupt an actively running subagent in its current turn. The message is added at the next model
|
|
592
592
|
boundary. This call waits for that model response and reports its ordinary text, whether the same
|
|
@@ -606,7 +606,7 @@ module LittleGhost
|
|
|
606
606
|
options = {}
|
|
607
607
|
options[:cancellation_token] = context.cancellation_token if context
|
|
608
608
|
options[:deadline] = context.deadline if context&.deadline
|
|
609
|
-
manager.
|
|
609
|
+
manager.interject(
|
|
610
610
|
subagent_id: input.fetch("subagent_id"),
|
|
611
611
|
message: input.fetch("message"),
|
|
612
612
|
**options
|
|
@@ -1089,9 +1089,9 @@ module LittleGhost
|
|
|
1089
1089
|
completion: Completion.new,
|
|
1090
1090
|
operation_id: SecureRandom.uuid,
|
|
1091
1091
|
parent_operation_id:,
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1092
|
+
interjections: [],
|
|
1093
|
+
interjection_metadata: context&.interjection_metadata,
|
|
1094
|
+
interjection_ids: context&.interjection_ids || []
|
|
1095
1095
|
)
|
|
1096
1096
|
identity.next_turn += 1
|
|
1097
1097
|
@turn_count += 1 if count_turn
|
|
@@ -1157,8 +1157,8 @@ module LittleGhost
|
|
|
1157
1157
|
options[:history] = identity.history
|
|
1158
1158
|
options[:context] = identity.state
|
|
1159
1159
|
options[:conversation_id] = identity.conversation_id
|
|
1160
|
-
options[:
|
|
1161
|
-
options[:
|
|
1160
|
+
options[:interjection_metadata] = turn.interjection_metadata
|
|
1161
|
+
options[:interjection_ids] = turn.interjection_ids
|
|
1162
1162
|
end
|
|
1163
1163
|
result = if identity.agent.is_a?(Agent)
|
|
1164
1164
|
run_agent_turn(identity, turn, options)
|
|
@@ -1284,8 +1284,8 @@ module LittleGhost
|
|
|
1284
1284
|
response = serialized_response[0, @max_response_chars] if truncated
|
|
1285
1285
|
persisted_response = response.is_a?(String) ? response : serialized_response
|
|
1286
1286
|
|
|
1287
|
-
|
|
1288
|
-
while identity.durable && turn.
|
|
1287
|
+
interject_exchanges = @mutex.synchronize do
|
|
1288
|
+
while identity.durable && turn.interjections.any? { |exchange| !exchange.complete } && !@closed
|
|
1289
1289
|
@condition.wait(@mutex, CANCELLATION_POLL_INTERVAL)
|
|
1290
1290
|
end
|
|
1291
1291
|
if @closed
|
|
@@ -1294,11 +1294,11 @@ module LittleGhost
|
|
|
1294
1294
|
end
|
|
1295
1295
|
|
|
1296
1296
|
identity.status = "persisting" if identity.durable
|
|
1297
|
-
turn.
|
|
1297
|
+
turn.interjections.select(&:complete).map { |exchange| [exchange.message, exchange.response] }
|
|
1298
1298
|
end
|
|
1299
|
-
return unless
|
|
1299
|
+
return unless interject_exchanges
|
|
1300
1300
|
|
|
1301
|
-
retain_agent_conversation(identity, turn, result, persisted_response,
|
|
1301
|
+
retain_agent_conversation(identity, turn, result, persisted_response, interject_exchanges)
|
|
1302
1302
|
|
|
1303
1303
|
@mutex.synchronize do
|
|
1304
1304
|
identity.latest_turn = turn.number
|
|
@@ -1323,11 +1323,11 @@ module LittleGhost
|
|
|
1323
1323
|
end
|
|
1324
1324
|
end
|
|
1325
1325
|
|
|
1326
|
-
def retain_agent_conversation(identity, turn, result, persisted_response,
|
|
1326
|
+
def retain_agent_conversation(identity, turn, result, persisted_response, interject_exchanges)
|
|
1327
1327
|
if identity.durable
|
|
1328
1328
|
state = result.is_a?(RunResult) ? result.state : identity.state
|
|
1329
1329
|
messages = [Message.new(role: :user, content: turn.message)]
|
|
1330
|
-
|
|
1330
|
+
interject_exchanges.each do |message, response|
|
|
1331
1331
|
messages << Message.new(role: :user, content: message)
|
|
1332
1332
|
messages << Message.new(role: :assistant, content: response)
|
|
1333
1333
|
end
|
|
@@ -31,6 +31,8 @@ module LittleGhost
|
|
|
31
31
|
items.each_index { |index| queue << index }
|
|
32
32
|
results = Array.new(items.length)
|
|
33
33
|
errors = Array.new(items.length)
|
|
34
|
+
error_mutex = Mutex.new
|
|
35
|
+
first_worker_error = nil
|
|
34
36
|
worker_count = [@max_concurrency, items.length].min
|
|
35
37
|
execution_state = ExecutionState.capture
|
|
36
38
|
|
|
@@ -49,6 +51,8 @@ module LittleGhost
|
|
|
49
51
|
results[index] = block.call(items[index])
|
|
50
52
|
rescue => error
|
|
51
53
|
errors[index] = error
|
|
54
|
+
error_mutex.synchronize { first_worker_error ||= error }
|
|
55
|
+
cancellation_token.cancel
|
|
52
56
|
ensure
|
|
53
57
|
completions << index
|
|
54
58
|
end
|
|
@@ -56,16 +60,24 @@ module LittleGhost
|
|
|
56
60
|
end
|
|
57
61
|
end
|
|
58
62
|
end
|
|
63
|
+
callback_error = nil
|
|
59
64
|
begin
|
|
60
65
|
items.length.times do
|
|
61
66
|
index = completions.pop
|
|
62
|
-
|
|
67
|
+
begin
|
|
68
|
+
on_result.call(index, results[index]) if on_result && !errors[index]
|
|
69
|
+
rescue => error
|
|
70
|
+
callback_error = error
|
|
71
|
+
cancellation_token.cancel
|
|
72
|
+
break
|
|
73
|
+
end
|
|
63
74
|
end
|
|
64
75
|
ensure
|
|
65
76
|
workers.each(&:join)
|
|
66
77
|
end
|
|
67
78
|
|
|
68
|
-
|
|
79
|
+
raise callback_error if callback_error
|
|
80
|
+
first_error = errors.compact.find { |error| error.is_a?(CleanupError) } || first_worker_error
|
|
69
81
|
raise first_error if first_error
|
|
70
82
|
|
|
71
83
|
results
|
|
@@ -5,7 +5,7 @@ require "monitor"
|
|
|
5
5
|
|
|
6
6
|
module LittleGhost
|
|
7
7
|
module Support
|
|
8
|
-
# Loader finds agents and tools from a conventional application layout. Ruby
|
|
8
|
+
# Loader finds agents, assemblies, and tools from a conventional application layout. Ruby
|
|
9
9
|
# files map to constants by path, so applications can add extension classes
|
|
10
10
|
# without maintaining a manual require list.
|
|
11
11
|
#
|
|
@@ -16,7 +16,7 @@ module LittleGhost
|
|
|
16
16
|
# checked for symbolic-link escapes. Application load roots are trusted code,
|
|
17
17
|
# not a sandbox for untrusted files.
|
|
18
18
|
class Loader
|
|
19
|
-
DEFAULT_DIRECTORIES = %w[app/agents app/tools].freeze # :nodoc:
|
|
19
|
+
DEFAULT_DIRECTORIES = %w[app/agents app/assemblies app/tools].freeze # :nodoc:
|
|
20
20
|
PROCESS_LOCK = Monitor.new # :nodoc:
|
|
21
21
|
|
|
22
22
|
# Application root and configured relative load directories.
|
data/lib/little_ghost/support.rb
CHANGED
|
@@ -9,17 +9,29 @@ module LittleGhost
|
|
|
9
9
|
|
|
10
10
|
# Recursively duplicates hashes, arrays, and strings while preserving other
|
|
11
11
|
# values. Cyclic containers are not supported.
|
|
12
|
-
def deep_dup(value)
|
|
12
|
+
def deep_dup(value, ancestors = {})
|
|
13
|
+
if value.is_a?(Hash) || value.is_a?(Array) || value.is_a?(Data)
|
|
14
|
+
identity = value.object_id
|
|
15
|
+
raise ArgumentError, "cyclic values cannot be duplicated" if ancestors.key?(identity)
|
|
16
|
+
|
|
17
|
+
ancestors[identity] = true
|
|
18
|
+
end
|
|
13
19
|
case value
|
|
14
20
|
when Hash
|
|
15
|
-
value.each_with_object({})
|
|
21
|
+
value.each_with_object({}) do |(key, child), copy|
|
|
22
|
+
copy[deep_dup(key, ancestors)] = deep_dup(child, ancestors)
|
|
23
|
+
end
|
|
16
24
|
when Array
|
|
17
|
-
value.map { |child| deep_dup(child) }
|
|
25
|
+
value.map { |child| deep_dup(child, ancestors) }
|
|
18
26
|
when String
|
|
19
27
|
value.dup
|
|
28
|
+
when Data
|
|
29
|
+
value.class.new(**value.to_h.transform_values { |child| deep_dup(child, ancestors) })
|
|
20
30
|
else
|
|
21
31
|
value
|
|
22
32
|
end
|
|
33
|
+
ensure
|
|
34
|
+
ancestors.delete(identity) if identity
|
|
23
35
|
end
|
|
24
36
|
end
|
|
25
37
|
end
|