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.
@@ -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