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,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LittleGhost
|
|
4
|
+
module Providers
|
|
5
|
+
class SSEParser # :nodoc:
|
|
6
|
+
def initialize
|
|
7
|
+
@buffer = +""
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def <<(chunk)
|
|
11
|
+
@buffer << chunk.to_s
|
|
12
|
+
@buffer.gsub!("\r\n", "\n")
|
|
13
|
+
events = []
|
|
14
|
+
|
|
15
|
+
while (boundary = @buffer.index("\n\n"))
|
|
16
|
+
frame = @buffer.slice!(0, boundary + 2)
|
|
17
|
+
data = frame.lines.filter_map do |line|
|
|
18
|
+
next unless line.start_with?("data:")
|
|
19
|
+
|
|
20
|
+
line.delete_prefix("data:").sub(/\A /, "").chomp
|
|
21
|
+
end
|
|
22
|
+
events << data.join("\n") unless data.empty?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
events
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def finish
|
|
29
|
+
return [] if @buffer.empty?
|
|
30
|
+
|
|
31
|
+
self << "\n\n"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module LittleGhost
|
|
6
|
+
# Observe one top-level agent or workflow execution from start to finish.
|
|
7
|
+
# A run records its response, outcome, usage, error, and owned resources.
|
|
8
|
+
#
|
|
9
|
+
# run = CustomerSupportAgent.ask("Why is transfer 481 pending?")
|
|
10
|
+
#
|
|
11
|
+
# run.completed? # => true
|
|
12
|
+
# run.outcome # => "completed"
|
|
13
|
+
# run.response # => "Transfer 481 is waiting for the receiving bank."
|
|
14
|
+
#
|
|
15
|
+
# Agent.ask[rdoc-ref:LittleGhost::Agent.ask] or standalone
|
|
16
|
+
# ask[rdoc-ref:LittleGhost::Agent#ask] consumes the event stream and returns
|
|
17
|
+
# the Run. For a live interface,
|
|
18
|
+
# stream_ask[rdoc-ref:LittleGhost::Agent#stream_ask] yields StreamEvent objects
|
|
19
|
+
# and returns the same run after enumeration. A run can execute only once.
|
|
20
|
+
#
|
|
21
|
+
# Completion, failure, deadline, and cancellation become the +completed+,
|
|
22
|
+
# +failed+, +partial+, and +cancelled+ outcomes. Ordinary execution failures
|
|
23
|
+
# are available through +error+ and the terminal stream event; cleanup, event
|
|
24
|
+
# delivery, or instrumentation failures may still raise because the framework
|
|
25
|
+
# cannot safely report a clean stop.
|
|
26
|
+
#
|
|
27
|
+
# The run opens its workspace, sandbox, session, and entrypoint, then closes
|
|
28
|
+
# registered resources in reverse order. +register+ extends that lifecycle for
|
|
29
|
+
# application resources. Interruption is available only while an agent
|
|
30
|
+
# entrypoint is active and unambiguous.
|
|
31
|
+
class Run
|
|
32
|
+
include Enumerable
|
|
33
|
+
|
|
34
|
+
# Runtime and declarations used to execute the run; its request, cancellation
|
|
35
|
+
# token, resources, terminal outcome, response, result, usage, and error.
|
|
36
|
+
attr_reader :runtime, :agent_class, :entrypoint_class, :invocation, :cancellation_token, :result, :operation_id,
|
|
37
|
+
:outcome, :response, :error, :session, :usage, :workspace, :sandbox
|
|
38
|
+
|
|
39
|
+
# Creates a dormant run for +invocation+.
|
|
40
|
+
def initialize(invocation:, agent_class:, runtime:, entrypoint_class: agent_class,
|
|
41
|
+
cancellation_token: Support::CancellationToken.new, workspace: nil, sandbox: nil)
|
|
42
|
+
@runtime = runtime
|
|
43
|
+
@agent_class = agent_class
|
|
44
|
+
@entrypoint_class = entrypoint_class
|
|
45
|
+
@invocation = invocation
|
|
46
|
+
@cancellation_token = cancellation_token
|
|
47
|
+
@workspace = workspace
|
|
48
|
+
@sandbox = sandbox
|
|
49
|
+
@operation_id = SecureRandom.uuid
|
|
50
|
+
@resources = []
|
|
51
|
+
@closed = false
|
|
52
|
+
@started = false
|
|
53
|
+
@mutex = Mutex.new
|
|
54
|
+
@event_mutex = Mutex.new
|
|
55
|
+
@subagent_instrumentation_mutex = Mutex.new
|
|
56
|
+
@subagent_instrumentation = {}
|
|
57
|
+
@exclusive_tools_mutex = Mutex.new
|
|
58
|
+
@once_mutex = Mutex.new
|
|
59
|
+
@once_keys = {}
|
|
60
|
+
@interruption_mutex = Mutex.new
|
|
61
|
+
@interruption_state = :not_started
|
|
62
|
+
@entrypoint = nil
|
|
63
|
+
@usage = Usage.new
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Consumes the event stream and returns +self+.
|
|
67
|
+
def call
|
|
68
|
+
each { |_event| }
|
|
69
|
+
self
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Yields events and returns +self+ after the terminal event.
|
|
73
|
+
#
|
|
74
|
+
# Without a block, returns an Enumerator. A second execution raises Error.
|
|
75
|
+
def each
|
|
76
|
+
return enum_for(__method__) unless block_given?
|
|
77
|
+
|
|
78
|
+
begin_execution!
|
|
79
|
+
@emitter = ->(event) { yield_event(event) { |value| yield value } }
|
|
80
|
+
Instrumentation.with_context(correlation_attributes.except(:operation_id)) do
|
|
81
|
+
execute { |event| yield event }
|
|
82
|
+
end
|
|
83
|
+
self
|
|
84
|
+
ensure
|
|
85
|
+
@emitter = nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# True after successful completion.
|
|
89
|
+
def completed? = outcome == "completed"
|
|
90
|
+
|
|
91
|
+
# True after execution or cleanup failed.
|
|
92
|
+
def failed? = outcome == "failed"
|
|
93
|
+
|
|
94
|
+
# True when the deadline preserved a partial response.
|
|
95
|
+
def partial? = outcome == "partial"
|
|
96
|
+
|
|
97
|
+
# True when cancellation stopped the run without a response.
|
|
98
|
+
def cancelled? = outcome == "cancelled"
|
|
99
|
+
|
|
100
|
+
# Adds an interruption to the active entrypoint and waits for its response.
|
|
101
|
+
#
|
|
102
|
+
# Raises LittleGhost::AgentInterruptError before the entrypoint is ready,
|
|
103
|
+
# after it finishes, or when the entrypoint does not support interruptions.
|
|
104
|
+
def interrupt_response(
|
|
105
|
+
message,
|
|
106
|
+
interruption_id: nil,
|
|
107
|
+
batch_key: nil,
|
|
108
|
+
metadata: {},
|
|
109
|
+
cancellation_token: Support::CancellationToken.new,
|
|
110
|
+
deadline: nil
|
|
111
|
+
)
|
|
112
|
+
entrypoint = @interruption_mutex.synchronize do
|
|
113
|
+
case @interruption_state
|
|
114
|
+
when :not_started, :starting
|
|
115
|
+
raise AgentInterruptError, "Run entrypoint is not ready for interruptions"
|
|
116
|
+
when :terminal
|
|
117
|
+
raise AgentInterruptError, "Run has already finished"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
@entrypoint
|
|
121
|
+
end
|
|
122
|
+
unless entrypoint.respond_to?(:interrupt_response)
|
|
123
|
+
raise AgentInterruptError, "Run entrypoint does not support interruptions"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
entrypoint.interrupt_response(
|
|
127
|
+
message,
|
|
128
|
+
interruption_id:,
|
|
129
|
+
batch_key:,
|
|
130
|
+
metadata:,
|
|
131
|
+
cancellation_token:,
|
|
132
|
+
deadline:
|
|
133
|
+
)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Creates a RunContext with this run's cancellation token and deadline.
|
|
137
|
+
def context(state: {}, metadata: {})
|
|
138
|
+
RunContext.new(
|
|
139
|
+
state:,
|
|
140
|
+
cancellation_token:,
|
|
141
|
+
deadline: invocation.deadline_at,
|
|
142
|
+
metadata:
|
|
143
|
+
)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def publish(type, **data) # :nodoc:
|
|
147
|
+
event = StreamEvent.build(type, **data)
|
|
148
|
+
@event_mutex.synchronize { @emitter&.call(event) }
|
|
149
|
+
instrument_event(type, data)
|
|
150
|
+
event
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Adds a resource or closer to reverse-order cleanup and returns the resource.
|
|
154
|
+
#
|
|
155
|
+
# A resource must respond to +close+ unless a block supplies the cleanup
|
|
156
|
+
# operation. Registering after the run has closed raises Error.
|
|
157
|
+
def register(resource = nil, &closer)
|
|
158
|
+
callback = closer || close_callback(resource)
|
|
159
|
+
@mutex.synchronize do
|
|
160
|
+
raise Error, "run is already closed" if @closed
|
|
161
|
+
@resources << callback
|
|
162
|
+
end
|
|
163
|
+
resource
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def synchronize_exclusive_tools(&block) # :nodoc:
|
|
167
|
+
@exclusive_tools_mutex.synchronize(&block)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Performs the block at most once successfully for +key+ during this run.
|
|
171
|
+
#
|
|
172
|
+
# Concurrent callers are serialized. The caller that performs the block
|
|
173
|
+
# receives its value; later callers receive +nil+. If the block raises, the
|
|
174
|
+
# key is not recorded and a later call may retry it.
|
|
175
|
+
def once(key)
|
|
176
|
+
@once_mutex.synchronize do
|
|
177
|
+
return if @once_keys.key?(key)
|
|
178
|
+
|
|
179
|
+
value = yield
|
|
180
|
+
@once_keys[key] = true
|
|
181
|
+
value
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def prepare_interruption(payload) # :nodoc:
|
|
186
|
+
runtime.prepare_interruption(self, payload)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Closes registered resources in reverse order.
|
|
190
|
+
#
|
|
191
|
+
# The operation is idempotent. It attempts every closer and then raises the
|
|
192
|
+
# first LittleGhost::CleanupError, or otherwise the first cleanup exception.
|
|
193
|
+
def close
|
|
194
|
+
callbacks = @mutex.synchronize do
|
|
195
|
+
return if @closed
|
|
196
|
+
@closed = true
|
|
197
|
+
@resources.reverse
|
|
198
|
+
end
|
|
199
|
+
errors = []
|
|
200
|
+
callbacks.each do |callback|
|
|
201
|
+
callback.call
|
|
202
|
+
rescue => error
|
|
203
|
+
errors << error
|
|
204
|
+
end
|
|
205
|
+
cleanup_error = errors.find { |caught| caught.is_a?(CleanupError) } || errors.first
|
|
206
|
+
begin
|
|
207
|
+
finish_remaining_subagent_instrumentation(
|
|
208
|
+
outcome: cleanup_error ? :error : :cancelled,
|
|
209
|
+
error_type: cleanup_error&.class&.name
|
|
210
|
+
)
|
|
211
|
+
rescue => error
|
|
212
|
+
errors << error
|
|
213
|
+
end
|
|
214
|
+
error = errors.find { |caught| caught.is_a?(CleanupError) } || errors.first
|
|
215
|
+
raise error if error
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
private
|
|
219
|
+
|
|
220
|
+
def execute
|
|
221
|
+
started_at = monotonic_time
|
|
222
|
+
current_response = nil
|
|
223
|
+
last_response = +""
|
|
224
|
+
response_before_model_attempt = +""
|
|
225
|
+
terminal = nil
|
|
226
|
+
execution_cleanup_error = nil
|
|
227
|
+
@instrumentation_handle = Instrumentation.start(
|
|
228
|
+
:run,
|
|
229
|
+
parent: nil,
|
|
230
|
+
**correlation_attributes,
|
|
231
|
+
entrypoint_kind: workflow_run? ? :workflow : :agent,
|
|
232
|
+
workflow_name: workflow_run? ? entrypoint_name : nil,
|
|
233
|
+
trace_context: invocation[:parent_trace_context],
|
|
234
|
+
trace_links: invocation[:trace_links],
|
|
235
|
+
diagnostic: {input: diagnostic_invocation_message}
|
|
236
|
+
)
|
|
237
|
+
emit(:run_start, run_id: invocation.run_id, thread_id: invocation.session_id) { |event| yield event }
|
|
238
|
+
trace_context = Instrumentation.trace_context(operation_id:)
|
|
239
|
+
emit(:trace_context, context: trace_context) { |event| yield event } unless trace_context.nil? || trace_context.empty?
|
|
240
|
+
workspace&.open(run: self)
|
|
241
|
+
sandbox&.open(run: self)
|
|
242
|
+
@session = runtime.open_session(self)
|
|
243
|
+
agent = if entrypoint_class <= Agent
|
|
244
|
+
runtime.build_agent(entrypoint_class, run: self)
|
|
245
|
+
else
|
|
246
|
+
entrypoint_class.new(run: self, runtime:)
|
|
247
|
+
end
|
|
248
|
+
@interruption_mutex.synchronize do
|
|
249
|
+
@entrypoint = agent
|
|
250
|
+
end
|
|
251
|
+
register(agent)
|
|
252
|
+
invoke = lambda do
|
|
253
|
+
history = session ? session.history(fallback: invocation.history) : invocation.history
|
|
254
|
+
context = session ? session.state.merge(invocation.context) : invocation.context.dup
|
|
255
|
+
options = {
|
|
256
|
+
history:,
|
|
257
|
+
context:,
|
|
258
|
+
settings: invocation.settings,
|
|
259
|
+
template_locals: runtime.template_locals(run: self, agent:),
|
|
260
|
+
template_paths: Array(invocation[:template_paths]),
|
|
261
|
+
cancellation_token:,
|
|
262
|
+
deadline: invocation.deadline_at,
|
|
263
|
+
parent_operation_id: operation_id,
|
|
264
|
+
checkpoint: lambda do |messages:, state:, parent_operation_id:|
|
|
265
|
+
session&.checkpoint(messages:, state:, parent_operation_id:)
|
|
266
|
+
end
|
|
267
|
+
}
|
|
268
|
+
if agent.is_a?(Agent)
|
|
269
|
+
options[:interrupt_ready] = lambda do
|
|
270
|
+
@interruption_mutex.synchronize { @interruption_state = :active }
|
|
271
|
+
end
|
|
272
|
+
else
|
|
273
|
+
@interruption_mutex.synchronize { @interruption_state = :active }
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
agent.stream(invocation.message, **options).each do |event|
|
|
277
|
+
case event.type
|
|
278
|
+
when :model_start
|
|
279
|
+
response_before_model_attempt = last_response.dup
|
|
280
|
+
when :message_start
|
|
281
|
+
current_response = +""
|
|
282
|
+
when :text_delta
|
|
283
|
+
current_response ||= +""
|
|
284
|
+
current_response << event.data[:text].to_s
|
|
285
|
+
when :message_stop
|
|
286
|
+
completed_response = current_response.to_s.strip
|
|
287
|
+
last_response = completed_response unless completed_response.empty?
|
|
288
|
+
current_response = nil
|
|
289
|
+
when :model_retry
|
|
290
|
+
current_response = nil
|
|
291
|
+
last_response = response_before_model_attempt.dup
|
|
292
|
+
end
|
|
293
|
+
if event.type == :invocation_stop
|
|
294
|
+
@result = event.data[:result]
|
|
295
|
+
@usage = result.usage
|
|
296
|
+
elsif event.type == :invocation_error
|
|
297
|
+
@usage = event.data.fetch(:usage, usage)
|
|
298
|
+
end
|
|
299
|
+
yield event
|
|
300
|
+
end
|
|
301
|
+
session&.checkpoint_result(result) if result
|
|
302
|
+
end
|
|
303
|
+
session ? session.synchronize(&invoke) : invoke.call
|
|
304
|
+
@outcome = "completed"
|
|
305
|
+
@response = result&.text.to_s
|
|
306
|
+
terminal = [:run_stop, {outcome:, response:, result:}]
|
|
307
|
+
rescue DeadlineExceededError => caught
|
|
308
|
+
@error = caught
|
|
309
|
+
@outcome = "partial"
|
|
310
|
+
@response = current_response.to_s.strip
|
|
311
|
+
@response = last_response if @response.empty?
|
|
312
|
+
terminal = [:run_partial, {outcome:, response:, error: caught}]
|
|
313
|
+
rescue CancelledError => caught
|
|
314
|
+
@error = caught
|
|
315
|
+
@outcome = "cancelled"
|
|
316
|
+
@response = ""
|
|
317
|
+
terminal = [:run_cancel, {outcome:, response:, error: caught}]
|
|
318
|
+
rescue => caught
|
|
319
|
+
execution_cleanup_error = caught if caught.is_a?(CleanupError)
|
|
320
|
+
@error = caught
|
|
321
|
+
@outcome = "failed"
|
|
322
|
+
@response = ""
|
|
323
|
+
cleanup_failed = caught.is_a?(CleanupError)
|
|
324
|
+
terminal = [
|
|
325
|
+
:run_error,
|
|
326
|
+
{
|
|
327
|
+
outcome:,
|
|
328
|
+
error: caught,
|
|
329
|
+
message: cleanup_failed ? cleanup_error_message(caught) : error_message(caught),
|
|
330
|
+
cleanup_failed:
|
|
331
|
+
}
|
|
332
|
+
]
|
|
333
|
+
ensure
|
|
334
|
+
@interruption_mutex.synchronize do
|
|
335
|
+
@last_entrypoint = @entrypoint
|
|
336
|
+
@entrypoint = nil
|
|
337
|
+
@interruption_state = :terminal
|
|
338
|
+
end
|
|
339
|
+
resource_cleanup_error = nil
|
|
340
|
+
begin
|
|
341
|
+
close
|
|
342
|
+
rescue => caught
|
|
343
|
+
resource_cleanup_error = caught
|
|
344
|
+
reported_error = execution_cleanup_error || caught
|
|
345
|
+
@error = reported_error
|
|
346
|
+
@outcome = "failed"
|
|
347
|
+
@response = ""
|
|
348
|
+
terminal = [
|
|
349
|
+
:run_error,
|
|
350
|
+
{outcome:, error: reported_error, message: cleanup_error_message(reported_error), cleanup_failed: true}
|
|
351
|
+
]
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
stop_error = execution_cleanup_error || resource_cleanup_error || error
|
|
355
|
+
stop_attributes = {
|
|
356
|
+
outcome: ((execution_cleanup_error || resource_cleanup_error) ? "failed" : outcome)&.to_sym,
|
|
357
|
+
duration_ms: duration_ms(started_at),
|
|
358
|
+
error_type: stop_error&.class&.name,
|
|
359
|
+
diagnostic: {
|
|
360
|
+
output: failed? ? last_response : response,
|
|
361
|
+
exception: stop_error && diagnostic_exception(stop_error)
|
|
362
|
+
}.compact,
|
|
363
|
+
**usage_attributes(usage)
|
|
364
|
+
}.compact
|
|
365
|
+
terminal_delivery_error = begin
|
|
366
|
+
emit(terminal.first, **terminal.last) { |event| yield event } if terminal
|
|
367
|
+
nil
|
|
368
|
+
rescue => caught
|
|
369
|
+
caught
|
|
370
|
+
end
|
|
371
|
+
instrumentation_error = begin
|
|
372
|
+
@instrumentation_handle&.finish(**correlation_attributes.merge(stop_attributes))
|
|
373
|
+
nil
|
|
374
|
+
rescue => caught
|
|
375
|
+
caught
|
|
376
|
+
end
|
|
377
|
+
final_errors = [
|
|
378
|
+
execution_cleanup_error,
|
|
379
|
+
resource_cleanup_error,
|
|
380
|
+
terminal_delivery_error,
|
|
381
|
+
instrumentation_error
|
|
382
|
+
].compact
|
|
383
|
+
final_error = final_errors.find { |caught| caught.is_a?(CleanupError) } || final_errors.first
|
|
384
|
+
raise final_error if final_error
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def emit(type, **data, &block)
|
|
388
|
+
emit_event(StreamEvent.build(type, **data), &block)
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def emit_event(event)
|
|
392
|
+
yield event
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def yield_event(event)
|
|
396
|
+
yield event
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def instrument(name, attributes = {})
|
|
400
|
+
Instrumentation.publish(name, **correlation_attributes.merge(attributes.compact))
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def correlation_attributes
|
|
404
|
+
{
|
|
405
|
+
operation_id:,
|
|
406
|
+
run_id: invocation.run_id,
|
|
407
|
+
invocation_id: invocation.invocation_id,
|
|
408
|
+
session_id: invocation.session_id,
|
|
409
|
+
service_name: runtime.service_name,
|
|
410
|
+
agent_id: workflow_run? ? nil : entrypoint_name,
|
|
411
|
+
workflow_name: workflow_run? ? entrypoint_name : nil
|
|
412
|
+
}.compact
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def workflow_run? = entrypoint_class <= Workflow
|
|
416
|
+
|
|
417
|
+
def entrypoint_name
|
|
418
|
+
return entrypoint_class.name.to_s if workflow_run?
|
|
419
|
+
|
|
420
|
+
return @entrypoint.entrypoint_name if @entrypoint&.respond_to?(:entrypoint_name)
|
|
421
|
+
|
|
422
|
+
entrypoint_class.agent_id
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def instrument_subagent(data)
|
|
426
|
+
value = data.fetch(:event)
|
|
427
|
+
event = value[:event] || value["event"]
|
|
428
|
+
attributes = {
|
|
429
|
+
subagent_id: value[:subagent_id] || value["subagent_id"],
|
|
430
|
+
conversation_id: value[:conversation_id] || value["conversation_id"],
|
|
431
|
+
resumed: value[:resumed] || value["resumed"],
|
|
432
|
+
kind: value[:kind] || value["kind"],
|
|
433
|
+
turn: value[:turn] || value["turn"],
|
|
434
|
+
status: value[:status] || value["status"],
|
|
435
|
+
error_type: value[:error_type] || value["error_type"]
|
|
436
|
+
}.compact
|
|
437
|
+
parent_operation_id = value[:parent_operation_id] || value["parent_operation_id"] || operation_id
|
|
438
|
+
case event
|
|
439
|
+
when "factory_failed"
|
|
440
|
+
instrument(:subagent_factory_failed, attributes.merge(parent_operation_id:))
|
|
441
|
+
when "spawned", "message_queued"
|
|
442
|
+
subagent_operation_id = value[:operation_id] || value["operation_id"]
|
|
443
|
+
return unless subagent_operation_id
|
|
444
|
+
|
|
445
|
+
values = attributes.merge(
|
|
446
|
+
operation_id: subagent_operation_id,
|
|
447
|
+
parent_operation_id:,
|
|
448
|
+
agent_id: attributes[:subagent_id]
|
|
449
|
+
)
|
|
450
|
+
start_subagent_instrumentation(values)
|
|
451
|
+
instrument(:subagent_spawned, values)
|
|
452
|
+
when "turn_started"
|
|
453
|
+
supplied_operation_id = value[:operation_id] || value["operation_id"]
|
|
454
|
+
return unless supplied_operation_id
|
|
455
|
+
|
|
456
|
+
instrument(
|
|
457
|
+
:subagent_turn_started,
|
|
458
|
+
attributes.merge(operation_id: supplied_operation_id, parent_operation_id:)
|
|
459
|
+
)
|
|
460
|
+
when "turn_finished", "turn_failed", "cancelled"
|
|
461
|
+
supplied_operation_id = value[:operation_id] || value["operation_id"]
|
|
462
|
+
return unless supplied_operation_id
|
|
463
|
+
|
|
464
|
+
outcome = {"turn_finished" => :completed, "turn_failed" => :error, "cancelled" => :cancelled}.fetch(event)
|
|
465
|
+
values = attributes.merge(
|
|
466
|
+
operation_id: supplied_operation_id,
|
|
467
|
+
parent_operation_id:,
|
|
468
|
+
agent_id: attributes[:subagent_id],
|
|
469
|
+
outcome:,
|
|
470
|
+
error_type: (event == "turn_failed") ? attributes[:error_type] || "LittleGhost::SubagentError" : nil
|
|
471
|
+
).compact
|
|
472
|
+
instrument(:subagent_finished, values)
|
|
473
|
+
finish_subagent_instrumentation(supplied_operation_id, values)
|
|
474
|
+
end
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
def instrument_event(type, data)
|
|
478
|
+
case type.to_sym
|
|
479
|
+
when :subagent
|
|
480
|
+
instrument_subagent(data)
|
|
481
|
+
when :model_retry
|
|
482
|
+
error = data[:error]
|
|
483
|
+
error_class = error.class.name if error.is_a?(Exception)
|
|
484
|
+
error_class ||= error if error.is_a?(String) && error.match?(/\A[A-Z]\w*(?:::[A-Z]\w*)*\z/)
|
|
485
|
+
attributes = data.slice(:attempt, :delay, :error_code, :http_status, :partial_text)
|
|
486
|
+
instrument(:model_retry, attributes.merge(error_class:).compact)
|
|
487
|
+
end
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
def start_subagent_instrumentation(attributes)
|
|
491
|
+
operation_id = attributes.fetch(:operation_id)
|
|
492
|
+
@subagent_instrumentation_mutex.synchronize do
|
|
493
|
+
return if @subagent_instrumentation.key?(operation_id)
|
|
494
|
+
|
|
495
|
+
values = correlation_attributes.merge(attributes).except(:operation_id, :parent_operation_id)
|
|
496
|
+
@subagent_instrumentation[operation_id] = Instrumentation.start(
|
|
497
|
+
:subagent,
|
|
498
|
+
parent: attributes.fetch(:parent_operation_id),
|
|
499
|
+
operation_id:,
|
|
500
|
+
detached: true,
|
|
501
|
+
**values
|
|
502
|
+
)
|
|
503
|
+
end
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
def finish_subagent_instrumentation(operation_id, attributes)
|
|
507
|
+
handle = @subagent_instrumentation_mutex.synchronize do
|
|
508
|
+
@subagent_instrumentation.delete(operation_id)
|
|
509
|
+
end
|
|
510
|
+
return unless handle
|
|
511
|
+
|
|
512
|
+
handle.finish(**attributes.except(:operation_id, :parent_operation_id))
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
def finish_remaining_subagent_instrumentation(outcome:, error_type: nil)
|
|
516
|
+
handles = @subagent_instrumentation_mutex.synchronize do
|
|
517
|
+
@subagent_instrumentation.values.tap { @subagent_instrumentation.clear }
|
|
518
|
+
end
|
|
519
|
+
errors = handles.filter_map do |handle|
|
|
520
|
+
handle.finish(outcome:, error_type:)
|
|
521
|
+
nil
|
|
522
|
+
rescue => error
|
|
523
|
+
error
|
|
524
|
+
end
|
|
525
|
+
raise errors.first if errors.any?
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
def usage_attributes(usage)
|
|
529
|
+
usage.respond_to?(:to_h) ? usage.to_h : {}
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
def monotonic_time
|
|
533
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
def duration_ms(started_at)
|
|
537
|
+
((monotonic_time - started_at) * 1_000).round(3)
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
def begin_execution!
|
|
541
|
+
@mutex.synchronize do
|
|
542
|
+
raise Error, "run has already started" if @started
|
|
543
|
+
@started = true
|
|
544
|
+
end
|
|
545
|
+
@interruption_mutex.synchronize { @interruption_state = :starting }
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
def close_callback(resource)
|
|
549
|
+
raise ArgumentError, "resource must respond to close or a block must be provided" unless resource&.respond_to?(:close)
|
|
550
|
+
|
|
551
|
+
-> { resource.close }
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
def cleanup_error_message(error)
|
|
555
|
+
error_message(error)
|
|
556
|
+
rescue
|
|
557
|
+
"The run could not cleanly stop all work."
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
def error_message(error)
|
|
561
|
+
runtime.error_message(error, self)
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
def diagnostic_invocation_message
|
|
565
|
+
message = invocation.message
|
|
566
|
+
return message unless message.respond_to?(:text)
|
|
567
|
+
return message.text unless message.text.empty?
|
|
568
|
+
|
|
569
|
+
{
|
|
570
|
+
role: message.role,
|
|
571
|
+
content: message.content.map { |block| diagnostic_invocation_content(block) }
|
|
572
|
+
}
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
def diagnostic_invocation_content(block)
|
|
576
|
+
case block
|
|
577
|
+
when Content::Text
|
|
578
|
+
{type: "text", text: block.text}
|
|
579
|
+
when Content::Reasoning
|
|
580
|
+
{type: "reasoning", text: block.text}
|
|
581
|
+
when Content::Image
|
|
582
|
+
{type: "image", media_type: block.media_type, bytes: block.data.bytesize}
|
|
583
|
+
when Content::Document
|
|
584
|
+
{type: "document", media_type: block.media_type, name: block.name, bytes: block.data.bytesize}
|
|
585
|
+
when Content::ToolUse
|
|
586
|
+
{type: "tool_use", id: block.id, name: block.name, input: block.input}
|
|
587
|
+
when Content::ToolResult
|
|
588
|
+
{
|
|
589
|
+
type: "tool_result",
|
|
590
|
+
tool_use_id: block.tool_use_id,
|
|
591
|
+
content: block.content.to_s,
|
|
592
|
+
status: block.status
|
|
593
|
+
}
|
|
594
|
+
else
|
|
595
|
+
block.to_s
|
|
596
|
+
end
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
def diagnostic_exception(error)
|
|
600
|
+
{
|
|
601
|
+
type: error.class.name,
|
|
602
|
+
message: error.message,
|
|
603
|
+
stacktrace: Array(error.backtrace).join("\n")
|
|
604
|
+
}
|
|
605
|
+
end
|
|
606
|
+
end
|
|
607
|
+
end
|