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,311 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LittleGhost
|
|
4
|
+
# Gives one agent or a coordinated group the same callable entrypoint.
|
|
5
|
+
#
|
|
6
|
+
# An assembly is anything callers can invoke like one Agent. An Agent is the
|
|
7
|
+
# smallest assembly because it owns one model loop. Workflow, Swarm, and Graph
|
|
8
|
+
# subclasses coordinate several participants while preserving the same
|
|
9
|
+
# +ask+, +stream_ask+, +call+, and +stream+ interface.
|
|
10
|
+
#
|
|
11
|
+
# agent_run = CustomerSupportAgent.ask("Why is my transfer pending?")
|
|
12
|
+
# graph_run = SupportFlowGraph.ask("Why is my transfer pending?")
|
|
13
|
+
#
|
|
14
|
+
# agent_run.response
|
|
15
|
+
# graph_run.response
|
|
16
|
+
#
|
|
17
|
+
# A standalone assembly owns a top-level Run. An assembly built by a Runtime
|
|
18
|
+
# participates in the existing run and returns a RunResult. Applications
|
|
19
|
+
# normally subclass Agent, Workflow, Swarm, or Graph rather than Assembly
|
|
20
|
+
# directly.
|
|
21
|
+
class Assembly
|
|
22
|
+
extend Support::ClassAttributes
|
|
23
|
+
|
|
24
|
+
class_attribute :assembly_id_value
|
|
25
|
+
class_attribute :description_value
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
# Executes +message+ through a fresh standalone assembly and returns its Run.
|
|
29
|
+
def ask(message, **options)
|
|
30
|
+
definition.implementation.new.ask(message, **options)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Lazily streams +message+ through a fresh standalone assembly.
|
|
34
|
+
def stream_ask(message, **options)
|
|
35
|
+
snapshot = definition
|
|
36
|
+
stream = nil
|
|
37
|
+
Enumerator.new do |events|
|
|
38
|
+
stream ||= snapshot.implementation.new.stream_ask(message, **options)
|
|
39
|
+
stream.each { |event| events << event }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Returns an immutable definition for this class.
|
|
44
|
+
def definition
|
|
45
|
+
if assembly_kind == :assembly
|
|
46
|
+
implementation = dup
|
|
47
|
+
implementation.assembly_id(assembly_id)
|
|
48
|
+
implementation.description(description)
|
|
49
|
+
implementation.freeze
|
|
50
|
+
return AssemblyDefinition.new(
|
|
51
|
+
kind: :assembly,
|
|
52
|
+
assembly_id:,
|
|
53
|
+
description:,
|
|
54
|
+
implementation:
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
to_builder.definition
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Returns a mutable dynamic builder seeded by this class.
|
|
62
|
+
def to_builder
|
|
63
|
+
builder_class = {
|
|
64
|
+
agent: AgentBuilder,
|
|
65
|
+
workflow: WorkflowBuilder,
|
|
66
|
+
swarm: SwarmBuilder,
|
|
67
|
+
graph: GraphBuilder
|
|
68
|
+
}.fetch(assembly_kind)
|
|
69
|
+
builder_class.new(base: self)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# :call-seq:
|
|
73
|
+
# assembly_id() -> String
|
|
74
|
+
# assembly_id(value) -> String
|
|
75
|
+
#
|
|
76
|
+
# The stable identifier used for tools and telemetry. Named subclasses
|
|
77
|
+
# derive it from their underscored class name without their type suffix.
|
|
78
|
+
def assembly_id(*values)
|
|
79
|
+
return assembly_id_value || default_assembly_id if values.empty?
|
|
80
|
+
|
|
81
|
+
self.assembly_id_value = values.fetch(0).to_s
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# :call-seq:
|
|
85
|
+
# description() -> String
|
|
86
|
+
# description(value) -> String
|
|
87
|
+
#
|
|
88
|
+
# The human-readable description used when exposing the assembly as a tool.
|
|
89
|
+
def description(*values)
|
|
90
|
+
return description_value.to_s if values.empty?
|
|
91
|
+
|
|
92
|
+
self.description_value = values.fetch(0).to_s
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Returns +:agent+, +:workflow+, +:swarm+, +:graph+, or +:assembly+.
|
|
96
|
+
def assembly_kind
|
|
97
|
+
return :agent if defined?(Agent) && self <= Agent
|
|
98
|
+
return :workflow if defined?(Workflow) && self <= Workflow
|
|
99
|
+
return :swarm if defined?(Swarm) && self <= Swarm
|
|
100
|
+
return :graph if defined?(Graph) && self <= Graph
|
|
101
|
+
|
|
102
|
+
:assembly
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def default_assembly_id
|
|
108
|
+
name = self.name.to_s.split("::").last.to_s.sub(/(Agent|Workflow|Swarm|Graph)\z/, "")
|
|
109
|
+
value = name.gsub(/([a-z\d])([A-Z])/, "\\1_\\2").downcase
|
|
110
|
+
(value.empty? ? assembly_kind.to_s : value).freeze
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# The owning run, runtime, and optional standalone resources.
|
|
115
|
+
attr_reader :run, :runtime, :workspace, :sandbox
|
|
116
|
+
|
|
117
|
+
def initialize(run: nil, runtime: nil, workspace: nil, sandbox: nil, standalone: run.nil?) # :nodoc:
|
|
118
|
+
@run = run
|
|
119
|
+
@runtime = runtime || run&.runtime || Runtime.new(configuration: LittleGhost.configuration)
|
|
120
|
+
@workspace = workspace || (run.workspace if run&.respond_to?(:workspace))
|
|
121
|
+
@sandbox = sandbox || (run.sandbox if run&.respond_to?(:sandbox))
|
|
122
|
+
@standalone = standalone
|
|
123
|
+
@assembly_mutex = Mutex.new
|
|
124
|
+
@assembly_closed = false
|
|
125
|
+
@active_assemblies = []
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Builds the top-level Run used by a standalone assembly.
|
|
129
|
+
def build_run(payload) # :nodoc:
|
|
130
|
+
payload = payload.dup if payload.is_a?(Hash)
|
|
131
|
+
cancellation_token = if payload.is_a?(Hash)
|
|
132
|
+
payload.delete(:cancellation_token) || payload.delete("cancellation_token")
|
|
133
|
+
end
|
|
134
|
+
source_class = self.class.respond_to?(:assembly_source_class) ? self.class.assembly_source_class : self.class
|
|
135
|
+
options = {entrypoint_class: source_class}
|
|
136
|
+
options[:execution_class] = self.class unless source_class.equal?(self.class)
|
|
137
|
+
options[:agent_class] = source_class if is_a?(Agent)
|
|
138
|
+
options[:cancellation_token] = cancellation_token if cancellation_token
|
|
139
|
+
options[:workspace] = workspace if workspace
|
|
140
|
+
options[:sandbox] = sandbox if sandbox
|
|
141
|
+
runtime.build_run(payload, **options)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Starts +payload+ on a supervised worker and returns an Execution.
|
|
145
|
+
def start_execution(payload, &event_consumer)
|
|
146
|
+
ensure_standalone!
|
|
147
|
+
Execution.start(build_run(payload), &event_consumer)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Runs +input+ to completion.
|
|
151
|
+
#
|
|
152
|
+
# A standalone assembly returns a Run. A run-scoped assembly returns its
|
|
153
|
+
# RunResult.
|
|
154
|
+
def call(input = nil, **options)
|
|
155
|
+
return build_run(entrypoint_payload(input, options)).call if standalone?
|
|
156
|
+
|
|
157
|
+
result = nil
|
|
158
|
+
stream(input, **options).each do |event|
|
|
159
|
+
result = event.data[:result] if event.type == :invocation_stop
|
|
160
|
+
end
|
|
161
|
+
result
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Runs +message+ to completion.
|
|
165
|
+
def ask(message, **options)
|
|
166
|
+
call(message, **options)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Lazily streams +message+ through the standalone or run-scoped assembly.
|
|
170
|
+
def stream_ask(message, **options)
|
|
171
|
+
if standalone?
|
|
172
|
+
options[:deadline_at] = options.delete(:deadline) if options.key?(:deadline)
|
|
173
|
+
stream = nil
|
|
174
|
+
return Enumerator.new do |events|
|
|
175
|
+
stream ||= build_run(entrypoint_payload(message, options)).each
|
|
176
|
+
stream.each { |event| events << event }
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
stream(message, **options)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Exposes this assembly as a Tool instance.
|
|
184
|
+
#
|
|
185
|
+
# By default each call has empty conversational history. Set
|
|
186
|
+
# <tt>preserve_context: true</tt> to retain history serially. Every call still
|
|
187
|
+
# receives the invoking Tool::Context application state; +preserve_context+
|
|
188
|
+
# does not suppress it. Tools inside the assembly remain responsible for
|
|
189
|
+
# authorizing privileged work from trusted context.
|
|
190
|
+
def as_tool(name: self.class.assembly_id, description: self.class.description, preserve_context: false)
|
|
191
|
+
assembly = self
|
|
192
|
+
description = "Delegate a task to #{name}." if description.to_s.empty?
|
|
193
|
+
mutex = Mutex.new
|
|
194
|
+
retained_history = []
|
|
195
|
+
tool_class = Tool.define(
|
|
196
|
+
name:,
|
|
197
|
+
description:,
|
|
198
|
+
input_schema: {
|
|
199
|
+
type: "object",
|
|
200
|
+
properties: {input: {type: "string"}},
|
|
201
|
+
required: ["input"],
|
|
202
|
+
additionalProperties: false
|
|
203
|
+
}
|
|
204
|
+
) do |input, context: nil|
|
|
205
|
+
invocation = lambda do
|
|
206
|
+
target = if assembly.is_a?(Agent)
|
|
207
|
+
assembly
|
|
208
|
+
elsif assembly.run
|
|
209
|
+
assembly.runtime.build_assembly(assembly.class, run: assembly.run)
|
|
210
|
+
else
|
|
211
|
+
assembly.class.new(runtime: assembly.runtime)
|
|
212
|
+
end
|
|
213
|
+
options = {
|
|
214
|
+
history: preserve_context ? retained_history : [],
|
|
215
|
+
context: context&.state || {},
|
|
216
|
+
cancellation_token: context&.cancellation_token || Support::CancellationToken.new,
|
|
217
|
+
deadline: context&.deadline,
|
|
218
|
+
parent_operation_id: assembly.run&.operation_id
|
|
219
|
+
}
|
|
220
|
+
if target.is_a?(Agent)
|
|
221
|
+
options[:interruption_metadata] = context&.interruption_metadata
|
|
222
|
+
options[:interruption_ids] = context&.interruption_ids || []
|
|
223
|
+
end
|
|
224
|
+
result = target.call(input.fetch("input"), **options)
|
|
225
|
+
if result.is_a?(Run)
|
|
226
|
+
raise result.error if result.error
|
|
227
|
+
|
|
228
|
+
result = result.result
|
|
229
|
+
end
|
|
230
|
+
raise ProtocolError, "assembly tool invocation did not return a result" unless result
|
|
231
|
+
|
|
232
|
+
retained_history.replace(result.messages.reject { |message| message.role == :system }) if preserve_context
|
|
233
|
+
result.structured? ? result.structured_result.value : result.text
|
|
234
|
+
ensure
|
|
235
|
+
target&.close unless target.equal?(assembly)
|
|
236
|
+
end
|
|
237
|
+
preserve_context ? mutex.synchronize(&invocation) : invocation.call
|
|
238
|
+
end
|
|
239
|
+
tool_class.define_method(:close) { assembly.close }
|
|
240
|
+
tool_class.new(binding: Tool::Binding.new(
|
|
241
|
+
agent: (self if is_a?(Agent)),
|
|
242
|
+
run:,
|
|
243
|
+
runtime:,
|
|
244
|
+
model: (model if respond_to?(:model)),
|
|
245
|
+
workspace:,
|
|
246
|
+
sandbox:
|
|
247
|
+
))
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Adds +message+ to the single active leaf Agent and returns its text reply.
|
|
251
|
+
def interrupt(message, **options)
|
|
252
|
+
interrupt_response(message, **options).text
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Adds an interruption to the single active leaf Agent.
|
|
256
|
+
def interrupt_response(message, **options)
|
|
257
|
+
child = @assembly_mutex.synchronize do
|
|
258
|
+
active = @active_assemblies.dup
|
|
259
|
+
if active.empty?
|
|
260
|
+
raise AgentInterruptError, "Assembly is not currently running"
|
|
261
|
+
end
|
|
262
|
+
if active.length > 1
|
|
263
|
+
raise AgentInterruptError, "Assembly has multiple active participants; the interruption target is ambiguous"
|
|
264
|
+
end
|
|
265
|
+
active.first
|
|
266
|
+
end
|
|
267
|
+
child.interrupt_response(message, **options)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Closes resources owned directly by this assembly.
|
|
271
|
+
def close
|
|
272
|
+
@assembly_mutex.synchronize do
|
|
273
|
+
return if @assembly_closed
|
|
274
|
+
|
|
275
|
+
@assembly_closed = true
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def entrypoint_name = self.class.assembly_id # :nodoc:
|
|
280
|
+
|
|
281
|
+
# Additional prompt locals made available to child agents.
|
|
282
|
+
def prompt_locals = {}
|
|
283
|
+
|
|
284
|
+
protected
|
|
285
|
+
|
|
286
|
+
def standalone? = @standalone # :nodoc:
|
|
287
|
+
|
|
288
|
+
def with_active_assembly(assembly) # :nodoc:
|
|
289
|
+
@assembly_mutex.synchronize do
|
|
290
|
+
raise Error, "assembly is already closed" if @assembly_closed
|
|
291
|
+
@active_assemblies << assembly
|
|
292
|
+
end
|
|
293
|
+
yield
|
|
294
|
+
ensure
|
|
295
|
+
@assembly_mutex.synchronize { @active_assemblies.delete(assembly) } if assembly
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def entrypoint_payload(input, options) # :nodoc:
|
|
299
|
+
return options if input.nil?
|
|
300
|
+
return input.merge(options) if input.is_a?(Hash)
|
|
301
|
+
|
|
302
|
+
{message: input, **options}
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
private
|
|
306
|
+
|
|
307
|
+
def ensure_standalone!
|
|
308
|
+
raise Error, "Only a standalone assembly can start an execution" unless standalone?
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
end
|