little_ghost 0.2.0 → 0.3.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 +19 -5
- data/docs/guides/core_concepts.md +167 -38
- data/docs/guides/getting_started.md +8 -4
- data/lib/little_ghost/agent/delegation.rb +34 -7
- data/lib/little_ghost/agent/tool_loop.rb +2 -1
- data/lib/little_ghost/agent.rb +115 -156
- data/lib/little_ghost/agent_builder.rb +20 -4
- data/lib/little_ghost/agent_factory.rb +3 -0
- data/lib/little_ghost/assembly.rb +311 -0
- data/lib/little_ghost/assembly_builder.rb +459 -0
- data/lib/little_ghost/assembly_execution.rb +452 -0
- data/lib/little_ghost/errors.rb +8 -0
- data/lib/little_ghost/execution.rb +206 -0
- data/lib/little_ghost/graph.rb +911 -0
- data/lib/little_ghost/run.rb +120 -32
- data/lib/little_ghost/run_result.rb +22 -11
- data/lib/little_ghost/runtime/hook.rb +7 -2
- data/lib/little_ghost/runtime.rb +64 -6
- data/lib/little_ghost/sandbox.rb +1 -1
- 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 +431 -0
- data/lib/little_ghost/tool.rb +32 -6
- data/lib/little_ghost/tracing/open_telemetry.rb +13 -2
- data/lib/little_ghost/unrestricted_sandbox.rb +1 -1
- data/lib/little_ghost/version.rb +1 -1
- data/lib/little_ghost/workflow.rb +198 -73
- data/lib/little_ghost.rb +11 -4
- metadata +11 -4
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "assembly"
|
|
5
|
+
|
|
6
|
+
module LittleGhost
|
|
7
|
+
# Lets configured Agent members hand one request directly to one another.
|
|
8
|
+
#
|
|
9
|
+
# A swarm is an Assembly for model-selected routing. One member is active at a
|
|
10
|
+
# time. It either produces the final answer or calls a reserved handoff tool
|
|
11
|
+
# to select an allowed next member.
|
|
12
|
+
#
|
|
13
|
+
# class ProblemSolverSwarm < LittleGhost::Swarm
|
|
14
|
+
# member TriageAgent
|
|
15
|
+
# member BillingAgent
|
|
16
|
+
# member AccountAgent
|
|
17
|
+
#
|
|
18
|
+
# start TriageAgent
|
|
19
|
+
# handoff TriageAgent, to: [BillingAgent, AccountAgent]
|
|
20
|
+
# max_steps 12
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# run = ProblemSolverSwarm.ask("Why was I charged twice?")
|
|
24
|
+
# run.response
|
|
25
|
+
#
|
|
26
|
+
# Swarm members are Agent definitions rather than arbitrary assemblies so a
|
|
27
|
+
# handoff remains a direct model-to-model transition. Original conversation
|
|
28
|
+
# history and application context stay isolated unless a member opts in with
|
|
29
|
+
# +history: true+ or +context: true+. Streams expose coordination lifecycle
|
|
30
|
+
# events and the final member response, but not intermediate model text.
|
|
31
|
+
class Swarm < Assembly
|
|
32
|
+
MAX_BUFFERED_EVENTS = 10_000 # :nodoc:
|
|
33
|
+
MAX_BUFFERED_EVENT_BYTES = 10 * 1024 * 1024 # :nodoc:
|
|
34
|
+
Member = Data.define(:id, :agent, :policies, :inherit_history, :inherit_context) # :nodoc:
|
|
35
|
+
Handoff = Data.define(:from, :to) # :nodoc:
|
|
36
|
+
|
|
37
|
+
extend Support::ClassAttributes
|
|
38
|
+
|
|
39
|
+
class_attribute :swarm_members_value, default: {}.freeze
|
|
40
|
+
class_attribute :swarm_handoffs_value, default: [].freeze
|
|
41
|
+
class_attribute :swarm_start_value
|
|
42
|
+
class_attribute :swarm_max_steps_value, default: 20
|
|
43
|
+
class_attribute :swarm_max_handoff_repeats_value, default: 3
|
|
44
|
+
|
|
45
|
+
class << self
|
|
46
|
+
# Declares one Agent member and its optional execution policy.
|
|
47
|
+
def member(agent, as: nil, timeout: nil, retries: 0, retry_on: nil, retry_delay: 0,
|
|
48
|
+
history: false, context: false)
|
|
49
|
+
validate_agent_reference!(agent)
|
|
50
|
+
id = normalize_member_id(as || agent_reference_id(agent))
|
|
51
|
+
raise ConfigurationError, "swarm member #{id.inspect} is already declared" if swarm_members_value.key?(id)
|
|
52
|
+
unless [history, context].all? { |value| value == true || value == false }
|
|
53
|
+
raise ArgumentError, "swarm member history and context options must be true or false"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
policies = {timeout:, retries:, retry_on:, retry_delay:}.freeze
|
|
57
|
+
declaration = Member.new(
|
|
58
|
+
id:, agent:, policies:,
|
|
59
|
+
inherit_history: history,
|
|
60
|
+
inherit_context: context
|
|
61
|
+
)
|
|
62
|
+
self.swarm_members_value = swarm_members_value.merge(id => declaration).freeze
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Reads or assigns the initial Agent member.
|
|
66
|
+
def start(member = nil)
|
|
67
|
+
return swarm_start_value if member.nil?
|
|
68
|
+
|
|
69
|
+
self.swarm_start_value = normalize_member_id(member_reference_id(member))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Restricts one member to the declared handoff targets.
|
|
73
|
+
def handoff(from, to:)
|
|
74
|
+
from = normalize_member_id(member_reference_id(from))
|
|
75
|
+
targets = Array(to).map { |target| normalize_member_id(member_reference_id(target)) }
|
|
76
|
+
raise ArgumentError, "handoff requires at least one target" if targets.empty?
|
|
77
|
+
raise ArgumentError, "handoff targets must be unique" unless targets.uniq.length == targets.length
|
|
78
|
+
|
|
79
|
+
declaration = Handoff.new(from:, to: targets.freeze)
|
|
80
|
+
self.swarm_handoffs_value = (swarm_handoffs_value + [declaration]).freeze
|
|
81
|
+
declaration
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Reads or assigns the maximum member executions.
|
|
85
|
+
def max_steps(value = nil)
|
|
86
|
+
return swarm_max_steps_value if value.nil?
|
|
87
|
+
|
|
88
|
+
value = Integer(value)
|
|
89
|
+
raise ArgumentError, "max_steps must be at least 1" if value < 1
|
|
90
|
+
|
|
91
|
+
self.swarm_max_steps_value = value
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Reads or assigns the repeated directed-handoff limit.
|
|
95
|
+
def max_handoff_repeats(value = nil)
|
|
96
|
+
return swarm_max_handoff_repeats_value if value.nil?
|
|
97
|
+
|
|
98
|
+
value = Integer(value)
|
|
99
|
+
raise ArgumentError, "max_handoff_repeats must be at least 1" if value < 1
|
|
100
|
+
|
|
101
|
+
self.swarm_max_handoff_repeats_value = value
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Validates the member set and topology and returns this Swarm class.
|
|
105
|
+
def validate!
|
|
106
|
+
swarm_definition!
|
|
107
|
+
self
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def swarm_definition! # :nodoc:
|
|
111
|
+
members = swarm_members_value
|
|
112
|
+
start_id = swarm_start_value
|
|
113
|
+
raise ConfigurationError, "swarm must declare at least two members" if members.length < 2
|
|
114
|
+
raise ConfigurationError, "swarm must declare a start member" unless start_id
|
|
115
|
+
raise ConfigurationError, "swarm start member #{start_id.inspect} is not declared" unless members.key?(start_id)
|
|
116
|
+
|
|
117
|
+
seen_sources = {}
|
|
118
|
+
members.each_value { |member| validate_step_policy!(member.policies) }
|
|
119
|
+
swarm_handoffs_value.each do |declaration|
|
|
120
|
+
raise ConfigurationError, "swarm handoff source #{declaration.from.inspect} is not declared" unless members.key?(declaration.from)
|
|
121
|
+
if seen_sources[declaration.from]
|
|
122
|
+
raise ConfigurationError, "swarm member #{declaration.from.inspect} has more than one handoff declaration"
|
|
123
|
+
end
|
|
124
|
+
seen_sources[declaration.from] = true
|
|
125
|
+
declaration.to.each do |target|
|
|
126
|
+
raise ConfigurationError, "swarm handoff target #{target.inspect} is not declared" unless members.key?(target)
|
|
127
|
+
raise ConfigurationError, "swarm member #{target.inspect} cannot hand off to itself" if target == declaration.from
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
[members, start_id, swarm_handoffs_value]
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
def validate_agent_reference!(value)
|
|
136
|
+
valid = (value.is_a?(Class) && value <= Agent) ||
|
|
137
|
+
value.is_a?(AgentBuilder) ||
|
|
138
|
+
(value.is_a?(AssemblyDefinition) && value.kind == :agent)
|
|
139
|
+
raise ConfigurationError, "swarm members must be Agent definitions" unless valid
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def agent_reference_id(value)
|
|
143
|
+
return value.agent_id if value.is_a?(Class)
|
|
144
|
+
|
|
145
|
+
value.assembly_id
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def member_reference_id(value)
|
|
149
|
+
return agent_reference_id(value) if value.is_a?(Class) || value.is_a?(AgentBuilder) || value.is_a?(AssemblyDefinition)
|
|
150
|
+
|
|
151
|
+
value
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def normalize_member_id(value)
|
|
155
|
+
value = value.to_s
|
|
156
|
+
raise ArgumentError, "swarm member id cannot be empty" if value.empty?
|
|
157
|
+
|
|
158
|
+
value.freeze
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def initialize(run: nil, runtime: nil) # :nodoc:
|
|
163
|
+
super(run:, runtime:, standalone: run.nil?)
|
|
164
|
+
@swarm_mutex = Mutex.new
|
|
165
|
+
@swarm_started = false
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Streams lifecycle events and only the final member's answer events.
|
|
169
|
+
def stream(input = nil, history: nil, context: nil,
|
|
170
|
+
cancellation_token: Support::CancellationToken.new, deadline: nil,
|
|
171
|
+
settings: nil, template_locals: nil, template_paths: nil,
|
|
172
|
+
parent_operation_id: nil, checkpoint: nil, **_options)
|
|
173
|
+
raise ArgumentError, "input is required" if input.nil?
|
|
174
|
+
if standalone?
|
|
175
|
+
return build_run(entrypoint_payload(input, {
|
|
176
|
+
history:, context:, settings:, template_paths:,
|
|
177
|
+
deadline_at: deadline, cancellation_token:
|
|
178
|
+
}.compact)).each
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
reserve_execution!
|
|
182
|
+
current_input = input.is_a?(Message) ? input : Message.new(role: :user, content: input)
|
|
183
|
+
original_history = normalize_history(history)
|
|
184
|
+
original_context = isolated_assembly_state(context || {})
|
|
185
|
+
settings ||= {}
|
|
186
|
+
template_locals ||= {}
|
|
187
|
+
template_paths ||= []
|
|
188
|
+
|
|
189
|
+
usage = Usage.new
|
|
190
|
+
error_emitted = false
|
|
191
|
+
Enumerator.new do |events|
|
|
192
|
+
members, current, topology = self.class.swarm_definition!
|
|
193
|
+
steps = []
|
|
194
|
+
transitions = Hash.new(0)
|
|
195
|
+
step_number = 0
|
|
196
|
+
previous_step_id = nil
|
|
197
|
+
|
|
198
|
+
loop do
|
|
199
|
+
check_swarm_control!(cancellation_token, deadline, step_number)
|
|
200
|
+
step_number += 1
|
|
201
|
+
member = members.fetch(current)
|
|
202
|
+
allowed = allowed_targets(current, members, topology)
|
|
203
|
+
handoff_tool = handoff_tool_for(current, allowed, members) if allowed.any?
|
|
204
|
+
step_id = SecureRandom.uuid
|
|
205
|
+
events << StreamEvent.build(
|
|
206
|
+
:assembly_step_start,
|
|
207
|
+
assembly_id: self.class.assembly_id,
|
|
208
|
+
assembly_kind: :swarm,
|
|
209
|
+
step: step_number,
|
|
210
|
+
participant: current,
|
|
211
|
+
step_id:
|
|
212
|
+
)
|
|
213
|
+
execution = execute_assembly_step(
|
|
214
|
+
reference: member.agent,
|
|
215
|
+
participant: current,
|
|
216
|
+
input: current_input,
|
|
217
|
+
history: member.inherit_history ? original_history : [],
|
|
218
|
+
context: member.inherit_context ? original_context : {},
|
|
219
|
+
cancellation_token:, deadline:, settings:, template_locals:,
|
|
220
|
+
template_paths:, parent_operation_id:,
|
|
221
|
+
policies: member.policies,
|
|
222
|
+
predecessor_ids: Array(previous_step_id),
|
|
223
|
+
checkpoint: nil,
|
|
224
|
+
build_options: {tools: [handoff_tool].compact},
|
|
225
|
+
step_id:
|
|
226
|
+
) { |event| events << event }
|
|
227
|
+
result = execution.result
|
|
228
|
+
previous_step_id = execution.step.id
|
|
229
|
+
transition = active_transition(execution, allowed)
|
|
230
|
+
events << StreamEvent.build(
|
|
231
|
+
:assembly_step_stop,
|
|
232
|
+
assembly_id: self.class.assembly_id,
|
|
233
|
+
assembly_kind: :swarm,
|
|
234
|
+
step: step_number,
|
|
235
|
+
participant: current,
|
|
236
|
+
step_id: execution.step.id,
|
|
237
|
+
usage: execution.step.usage
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
if transition
|
|
241
|
+
usage += execution.step.usage
|
|
242
|
+
target = transition.fetch(:agent_id)
|
|
243
|
+
key = [current, target]
|
|
244
|
+
transitions[key] += 1
|
|
245
|
+
if transitions[key] > self.class.max_handoff_repeats
|
|
246
|
+
events << StreamEvent.build(
|
|
247
|
+
:assembly_handoff_loop,
|
|
248
|
+
assembly_id: self.class.assembly_id,
|
|
249
|
+
assembly_kind: :swarm,
|
|
250
|
+
from: current,
|
|
251
|
+
to: target,
|
|
252
|
+
count: transitions[key]
|
|
253
|
+
)
|
|
254
|
+
raise AssemblyLimitError, "swarm repeated handoff #{current.inspect} -> #{target.inspect} too many times"
|
|
255
|
+
end
|
|
256
|
+
sanitized = sanitized_handoff_step(execution.step, transition)
|
|
257
|
+
steps << sanitized
|
|
258
|
+
steps.concat(result.steps.drop(1))
|
|
259
|
+
events << StreamEvent.build(
|
|
260
|
+
:assembly_transition,
|
|
261
|
+
assembly_id: self.class.assembly_id,
|
|
262
|
+
assembly_kind: :swarm,
|
|
263
|
+
step: step_number,
|
|
264
|
+
from: current,
|
|
265
|
+
to: target
|
|
266
|
+
)
|
|
267
|
+
current_input = handoff_input(from: current, transition:)
|
|
268
|
+
current = target
|
|
269
|
+
next
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
usage += execution.step.usage
|
|
273
|
+
steps.concat(result.steps)
|
|
274
|
+
final = copy_run_result(result, usage:, steps: steps.freeze)
|
|
275
|
+
release_final_events(execution.events, final).each { |event| events << event }
|
|
276
|
+
break
|
|
277
|
+
end
|
|
278
|
+
rescue => error
|
|
279
|
+
usage += step_error_usage(error)
|
|
280
|
+
unless error_emitted
|
|
281
|
+
events << StreamEvent.build(:invocation_error, error:, usage:, metadata: {})
|
|
282
|
+
end
|
|
283
|
+
raise
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
private
|
|
288
|
+
|
|
289
|
+
def reserve_execution!
|
|
290
|
+
@swarm_mutex.synchronize do
|
|
291
|
+
raise Error, "swarm instances can only be streamed once" if @swarm_started
|
|
292
|
+
|
|
293
|
+
@swarm_started = true
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def check_swarm_control!(token, deadline, step)
|
|
298
|
+
token.raise_if_cancelled!
|
|
299
|
+
raise DeadlineExceededError, "The run deadline was reached" if deadline && Time.now >= deadline
|
|
300
|
+
if step >= self.class.max_steps
|
|
301
|
+
raise AssemblyLimitError, "#{self.class} reached its max_steps limit of #{self.class.max_steps}"
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def allowed_targets(current, members, topology)
|
|
306
|
+
return members.keys.reject { |id| id == current } if topology.empty?
|
|
307
|
+
|
|
308
|
+
topology.find { |declaration| declaration.from == current }&.to || []
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def handoff_tool_for(current, allowed, members)
|
|
312
|
+
descriptions = allowed.map do |id|
|
|
313
|
+
member = members.fetch(id)
|
|
314
|
+
description = member_description(member.agent)
|
|
315
|
+
description.empty? ? id : "#{id}: #{description}"
|
|
316
|
+
end.join("; ")
|
|
317
|
+
Class.new(Tool) do
|
|
318
|
+
tool_name "handoff_to_agent"
|
|
319
|
+
description "Hand the request to one available swarm member: #{descriptions}"
|
|
320
|
+
input_schema(
|
|
321
|
+
type: "object",
|
|
322
|
+
properties: {
|
|
323
|
+
agent_id: {type: "string", enum: allowed},
|
|
324
|
+
message: {type: "string"},
|
|
325
|
+
context: {type: "object", additionalProperties: true}
|
|
326
|
+
},
|
|
327
|
+
required: %w[agent_id message],
|
|
328
|
+
additionalProperties: false
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
define_method(:call) do |input|
|
|
332
|
+
payload = {
|
|
333
|
+
agent_id: input.fetch("agent_id"),
|
|
334
|
+
message: input.fetch("message"),
|
|
335
|
+
context: input["context"]
|
|
336
|
+
}.compact.freeze
|
|
337
|
+
agent.request_assembly_transition(payload, context: context)
|
|
338
|
+
"Handing off to #{payload.fetch(:agent_id)}."
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def member_description(reference)
|
|
344
|
+
return reference.description if reference.respond_to?(:description)
|
|
345
|
+
return reference.implementation.description if reference.is_a?(AssemblyDefinition)
|
|
346
|
+
|
|
347
|
+
""
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def active_transition(execution, allowed)
|
|
351
|
+
transition = execution.transition
|
|
352
|
+
return nil unless transition
|
|
353
|
+
unless transition.is_a?(Hash)
|
|
354
|
+
raise ProtocolError, "swarm member requested a handoff without a transition payload"
|
|
355
|
+
end
|
|
356
|
+
target = transition[:agent_id] || transition["agent_id"]
|
|
357
|
+
raise AssemblyRoutingError, "swarm member selected unavailable target #{target.inspect}" unless allowed.include?(target)
|
|
358
|
+
|
|
359
|
+
{agent_id: target, message: transition[:message] || transition["message"], context: transition[:context] || transition["context"]}.compact
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def sanitized_handoff_step(step, transition)
|
|
363
|
+
output, truncated = projected_step_output(transition)
|
|
364
|
+
Step.new(**step.to_h.merge(output:, output_truncated: truncated))
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def step_error_usage(error) = error.instance_variable_get(:@little_ghost_step_usage) || Usage.new
|
|
368
|
+
|
|
369
|
+
def handoff_input(from:, transition:)
|
|
370
|
+
text = "Handoff from #{from}:\n#{transition.fetch(:message)}"
|
|
371
|
+
if transition[:context]
|
|
372
|
+
text << "\n\nAdditional context supplied by the previous agent:\n"
|
|
373
|
+
text << JSON.generate(transition.fetch(:context))
|
|
374
|
+
end
|
|
375
|
+
Message.new(role: :user, content: text)
|
|
376
|
+
rescue JSON::GeneratorError
|
|
377
|
+
raise AssemblyRoutingError, "swarm handoff context must be JSON-compatible"
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def release_final_events(events, final)
|
|
381
|
+
buffer = []
|
|
382
|
+
bytes = 0
|
|
383
|
+
events.each do |event|
|
|
384
|
+
event = StreamEvent.build(event.type, **event.data.merge(result: final)) if event.type == :invocation_stop
|
|
385
|
+
bytes = buffer_event!(buffer, event, bytes:)
|
|
386
|
+
end
|
|
387
|
+
buffer
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def buffer_event!(buffer, event, bytes:)
|
|
391
|
+
raise AssemblyLimitError, "swarm member emitted too many buffered events" if buffer.length >= MAX_BUFFERED_EVENTS
|
|
392
|
+
|
|
393
|
+
bytes += buffered_size(event)
|
|
394
|
+
raise AssemblyLimitError, "swarm member emitted too much buffered event data" if bytes > MAX_BUFFERED_EVENT_BYTES
|
|
395
|
+
|
|
396
|
+
buffer << event
|
|
397
|
+
bytes
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def buffered_size(value, ancestors = {}, depth = 0)
|
|
401
|
+
return MAX_BUFFERED_EVENT_BYTES + 1 if depth > 32
|
|
402
|
+
|
|
403
|
+
identity = value.object_id
|
|
404
|
+
return 0 if ancestors.key?(identity)
|
|
405
|
+
|
|
406
|
+
case value
|
|
407
|
+
when String
|
|
408
|
+
value.bytesize
|
|
409
|
+
when Hash
|
|
410
|
+
ancestors[identity] = true
|
|
411
|
+
value.sum { |key, item| buffered_size(key, ancestors, depth + 1) + buffered_size(item, ancestors, depth + 1) }
|
|
412
|
+
when Array
|
|
413
|
+
ancestors[identity] = true
|
|
414
|
+
value.sum { |item| buffered_size(item, ancestors, depth + 1) }
|
|
415
|
+
when Data
|
|
416
|
+
ancestors[identity] = true
|
|
417
|
+
value.members.sum { |member| buffered_size(value.public_send(member), ancestors, depth + 1) }
|
|
418
|
+
else
|
|
419
|
+
64
|
|
420
|
+
end
|
|
421
|
+
ensure
|
|
422
|
+
ancestors.delete(identity) if identity
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def normalize_history(value)
|
|
426
|
+
return [].freeze if value.nil?
|
|
427
|
+
|
|
428
|
+
Array(value).map { |message| Message.coerce(message) }.freeze
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
end
|
data/lib/little_ghost/tool.rb
CHANGED
|
@@ -76,9 +76,16 @@ module LittleGhost
|
|
|
76
76
|
# Report the caller-safe outcome of one tool execution.
|
|
77
77
|
# The result retains model-facing content, status, and the original exception
|
|
78
78
|
# for application-side inspection.
|
|
79
|
-
ExecutionResult = Data.define(:content, :status, :error) do # :nodoc:
|
|
80
|
-
def initialize(content:, status:, error: nil)
|
|
81
|
-
|
|
79
|
+
ExecutionResult = Data.define(:content, :status, :error, :companion_content) do # :nodoc:
|
|
80
|
+
def initialize(content:, status:, error: nil, companion_content: [])
|
|
81
|
+
companions = Array(companion_content)
|
|
82
|
+
unless companions.all? do |block|
|
|
83
|
+
block.is_a?(Content::Text) || block.is_a?(Content::Image) || block.is_a?(Content::Document)
|
|
84
|
+
end
|
|
85
|
+
raise ArgumentError, "companion content must contain only text, images, or documents"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
super(content:, status:, error:, companion_content: companions.dup.freeze)
|
|
82
89
|
end
|
|
83
90
|
|
|
84
91
|
def success?
|
|
@@ -92,12 +99,14 @@ module LittleGhost
|
|
|
92
99
|
|
|
93
100
|
# Reports the caller-safe outcome of one tool execution. It keeps
|
|
94
101
|
# model-facing content and status beside the original exception retained for
|
|
95
|
-
# application-side inspection.
|
|
102
|
+
# application-side inspection. Companion content lets a tool place trusted
|
|
103
|
+
# text, images, or documents in the next model request without putting those
|
|
104
|
+
# blocks inside a provider-specific tool-result shape.
|
|
96
105
|
class ExecutionResult < Data # :doc:
|
|
97
106
|
##
|
|
98
107
|
# :singleton-method: new
|
|
99
108
|
# :call-seq:
|
|
100
|
-
# new(content:, status:, error: nil) -> ExecutionResult
|
|
109
|
+
# new(content:, status:, error: nil, companion_content: []) -> ExecutionResult
|
|
101
110
|
#
|
|
102
111
|
# Collects the model-facing and application-facing parts of one result.
|
|
103
112
|
|
|
@@ -113,6 +122,11 @@ module LittleGhost
|
|
|
113
122
|
# :attr_reader: error
|
|
114
123
|
# The original exception for application-side inspection, when present.
|
|
115
124
|
|
|
125
|
+
##
|
|
126
|
+
# :attr_reader: companion_content
|
|
127
|
+
# Frozen Text, Image, and Document blocks appended as a transient user
|
|
128
|
+
# message after the ordinary tool result.
|
|
129
|
+
|
|
116
130
|
##
|
|
117
131
|
# :method: success?
|
|
118
132
|
# Indicates that +status+ is +:success+.
|
|
@@ -292,7 +306,10 @@ module LittleGhost
|
|
|
292
306
|
return failure(message, error: ToolError.new(message))
|
|
293
307
|
end
|
|
294
308
|
|
|
295
|
-
|
|
309
|
+
value = bound_for(context).call(input)
|
|
310
|
+
return normalize_execution_result(value) if value.is_a?(ExecutionResult)
|
|
311
|
+
|
|
312
|
+
success(sanitize(value))
|
|
296
313
|
rescue CancelledError, DeadlineExceededError, CleanupError
|
|
297
314
|
raise
|
|
298
315
|
rescue ToolError => error
|
|
@@ -345,6 +362,15 @@ module LittleGhost
|
|
|
345
362
|
ExecutionResult.new(content: content.freeze, status: :error, error:)
|
|
346
363
|
end
|
|
347
364
|
|
|
365
|
+
def normalize_execution_result(result)
|
|
366
|
+
ExecutionResult.new(
|
|
367
|
+
content: sanitize(result.content).freeze,
|
|
368
|
+
status: result.status,
|
|
369
|
+
error: result.error,
|
|
370
|
+
companion_content: result.companion_content
|
|
371
|
+
)
|
|
372
|
+
end
|
|
373
|
+
|
|
348
374
|
class SchemaValidator # :nodoc:
|
|
349
375
|
def initialize(schema)
|
|
350
376
|
@schema = schema
|
|
@@ -42,6 +42,8 @@ module LittleGhost
|
|
|
42
42
|
tool_type: "gen_ai.tool.type",
|
|
43
43
|
tool_call_id: "gen_ai.tool.call.id",
|
|
44
44
|
workflow_name: "gen_ai.workflow.name",
|
|
45
|
+
assembly_id: "little_ghost.assembly.id",
|
|
46
|
+
assembly_kind: "little_ghost.assembly.kind",
|
|
45
47
|
http_response_status_code: "http.response.status_code",
|
|
46
48
|
error_class: "error.type",
|
|
47
49
|
error_type: "error.type"
|
|
@@ -54,8 +56,12 @@ module LittleGhost
|
|
|
54
56
|
runtime: "runtime_startup",
|
|
55
57
|
session_store: "session_store",
|
|
56
58
|
subagent: "invoke_agent",
|
|
59
|
+
assembly_step: "execute_assembly_step",
|
|
57
60
|
tool: "execute_tool",
|
|
58
|
-
workflow: "invoke_workflow"
|
|
61
|
+
workflow: "invoke_workflow",
|
|
62
|
+
swarm: "invoke_swarm",
|
|
63
|
+
graph: "invoke_graph",
|
|
64
|
+
assembly: "invoke_assembly"
|
|
59
65
|
}.freeze # :nodoc:
|
|
60
66
|
REQUEST_SETTING_ATTRIBUTES = {
|
|
61
67
|
frequency_penalty: "gen_ai.request.frequency_penalty",
|
|
@@ -151,7 +157,10 @@ module LittleGhost
|
|
|
151
157
|
|
|
152
158
|
def start_span(name, attributes)
|
|
153
159
|
kind = name.to_sym
|
|
154
|
-
|
|
160
|
+
if kind == :run
|
|
161
|
+
entrypoint_kind = attributes[:entrypoint_kind]&.to_sym
|
|
162
|
+
kind = entrypoint_kind if %i[workflow swarm graph assembly].include?(entrypoint_kind)
|
|
163
|
+
end
|
|
155
164
|
parent = @mutex.synchronize { @entries[attributes[:parent_operation_id]] }
|
|
156
165
|
if kind == :agent && parent&.fetch(:kind) == :run &&
|
|
157
166
|
parent[:agent_id].to_s == attributes[:agent_id].to_s
|
|
@@ -258,6 +267,8 @@ module LittleGhost
|
|
|
258
267
|
detail = case kind
|
|
259
268
|
when :agent, :run then attributes[:agent_name] || attributes[:agent_id]
|
|
260
269
|
when :workflow then attributes[:workflow_name]
|
|
270
|
+
when :swarm, :graph, :assembly then attributes[:assembly_id]
|
|
271
|
+
when :assembly_step then attributes[:participant]
|
|
261
272
|
when :agent_turn then attributes[:turn]
|
|
262
273
|
when :model then attributes[:model_id]
|
|
263
274
|
when :subagent then attributes[:subagent_id] || attributes[:kind]
|
data/lib/little_ghost/version.rb
CHANGED