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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +72 -74
  3. data/docs/guides/assemblies.md +286 -0
  4. data/docs/guides/core_concepts.md +159 -135
  5. data/docs/guides/getting_started.md +114 -83
  6. data/docs/guides/production.md +187 -0
  7. data/docs/guides/prompt_views.md +132 -0
  8. data/lib/little_ghost/ag_ui/adapter.rb +3 -3
  9. data/lib/little_ghost/agent/delegation.rb +35 -8
  10. data/lib/little_ghost/agent/tool_loop.rb +2 -1
  11. data/lib/little_ghost/agent.rb +280 -326
  12. data/lib/little_ghost/agent_builder.rb +20 -4
  13. data/lib/little_ghost/agent_factory.rb +3 -0
  14. data/lib/little_ghost/{agent_interruptions.rb → agent_interjections.rb} +12 -12
  15. data/lib/little_ghost/assembly.rb +345 -0
  16. data/lib/little_ghost/assembly_builder.rb +497 -0
  17. data/lib/little_ghost/assembly_execution.rb +535 -0
  18. data/lib/little_ghost/configuration.rb +263 -39
  19. data/lib/little_ghost/content.rb +5 -5
  20. data/lib/little_ghost/data_map.rb +209 -0
  21. data/lib/little_ghost/errors.rb +10 -2
  22. data/lib/little_ghost/execution.rb +206 -0
  23. data/lib/little_ghost/graph.rb +930 -0
  24. data/lib/little_ghost/message.rb +4 -4
  25. data/lib/little_ghost/model_resolver.rb +2 -2
  26. data/lib/little_ghost/prompt_resolver.rb +2 -0
  27. data/lib/little_ghost/run.rb +190 -64
  28. data/lib/little_ghost/run_context.rb +33 -20
  29. data/lib/little_ghost/run_result.rb +22 -11
  30. data/lib/little_ghost/runtime/hook.rb +9 -4
  31. data/lib/little_ghost/runtime.rb +134 -36
  32. data/lib/little_ghost/sandbox.rb +1 -1
  33. data/lib/little_ghost/session.rb +12 -23
  34. data/lib/little_ghost/session_store.rb +9 -5
  35. data/lib/little_ghost/session_stores/agent_core_memory.rb +64 -56
  36. data/lib/little_ghost/session_stores/filesystem.rb +261 -0
  37. data/lib/little_ghost/session_stores/memory.rb +7 -0
  38. data/lib/little_ghost/subagents/manager.rb +42 -42
  39. data/lib/little_ghost/support/executor.rb +14 -2
  40. data/lib/little_ghost/support/loader.rb +2 -2
  41. data/lib/little_ghost/support.rb +15 -3
  42. data/lib/little_ghost/swarm.rb +439 -0
  43. data/lib/little_ghost/tool.rb +88 -20
  44. data/lib/little_ghost/tools/write_todos.rb +6 -1
  45. data/lib/little_ghost/tracing/open_telemetry.rb +14 -3
  46. data/lib/little_ghost/unrestricted_sandbox.rb +1 -1
  47. data/lib/little_ghost/version.rb +1 -1
  48. data/lib/little_ghost/workflow.rb +224 -90
  49. data/lib/little_ghost.rb +36 -25
  50. metadata +17 -5
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LittleGhost
4
- class AgentBuilder # :nodoc: all
4
+ class AgentFactory # :nodoc: all
5
5
  class ActivityRelay
6
6
  def initialize
7
7
  @mutex = Mutex.new
@@ -92,8 +92,8 @@ module LittleGhost
92
92
 
93
93
  def agent_tools(agent_class, run)
94
94
  tools = []
95
- agent_class.agent_tool_declarations.each do |declaration|
96
- child = declared_agent(declaration, run)
95
+ agent_class.assembly_tool_declarations.each do |declaration|
96
+ child = declared_assembly(declaration, run)
97
97
  begin
98
98
  tools << child.as_tool(
99
99
  name: declaration.fetch(:name),
@@ -111,6 +111,17 @@ module LittleGhost
111
111
  raise
112
112
  end
113
113
 
114
+ def declared_assembly(declaration, run)
115
+ assembly_class = declaration.fetch(:assembly)
116
+ if assembly_class.is_a?(AssemblyDefinition) && assembly_class.kind == :agent
117
+ declared_agent(declaration.merge(agent: assembly_class.implementation), run)
118
+ elsif assembly_class <= Agent
119
+ declared_agent(declaration.merge(agent: assembly_class), run)
120
+ else
121
+ runtime.build_assembly(assembly_class, run:)
122
+ end
123
+ end
124
+
114
125
  def subagent_tools(agent_class, run, conversation_id:, delegation_activity:, agent_path:)
115
126
  definitions = agent_class.subagent_declarations.map do |declaration|
116
127
  Subagents::Definition.new(
@@ -159,7 +170,12 @@ module LittleGhost
159
170
  end
160
171
 
161
172
  def declared_agent(declaration, run, conversation_id: nil, agent_path: Subagents::AgentPath::ROOT)
162
- agent_class = @resolve_agent.call(declaration.fetch(:agent))
173
+ reference = declaration.fetch(:agent)
174
+ agent_class = if reference.is_a?(AssemblyDefinition)
175
+ reference.implementation
176
+ else
177
+ @resolve_agent.call(reference)
178
+ end
163
179
  build_agent(
164
180
  agent_class, run:,
165
181
  model: resolve(declaration[:model], run),
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "agent_builder"
@@ -3,16 +3,16 @@
3
3
  require "securerandom"
4
4
 
5
5
  module LittleGhost
6
- class AgentInterruptions # :nodoc: all
6
+ class AgentInterjections # :nodoc: all
7
7
  MAX_BATCH_SIZE = 100
8
- MAX_INTERRUPTION_COUNT = 1_000
8
+ MAX_INTERJECTION_COUNT = 1_000
9
9
 
10
- Response = Data.define(:text, :tool_calls, :interruption_ids, :batch_key) do
11
- def initialize(text:, tool_calls:, interruption_ids: [], batch_key: nil)
10
+ Result = Data.define(:text, :tool_calls, :interjection_ids, :batch_key) do
11
+ def initialize(text:, tool_calls:, interjection_ids: [], batch_key: nil)
12
12
  super(
13
13
  text: String(text),
14
14
  tool_calls: !!tool_calls,
15
- interruption_ids: Array(interruption_ids).map { |id| String(id).dup.freeze }.freeze,
15
+ interjection_ids: Array(interjection_ids).map { |id| String(id).dup.freeze }.freeze,
16
16
  batch_key: batch_key.nil? ? nil : String(batch_key).dup.freeze
17
17
  )
18
18
  end
@@ -21,7 +21,7 @@ module LittleGhost
21
21
  end
22
22
 
23
23
  Batch = Data.define(:tickets) do
24
- def interruption_ids = tickets.map(&:id)
24
+ def interjection_ids = tickets.map(&:id)
25
25
  def batch_key = tickets.first&.batch_key
26
26
  def metadata = tickets.last&.metadata
27
27
  end
@@ -99,7 +99,7 @@ module LittleGhost
99
99
 
100
100
  def enqueue(message, id: SecureRandom.uuid, batch_key: nil, metadata: {})
101
101
  id = String(id)
102
- raise ArgumentError, "interruption_id cannot be empty" if id.empty?
102
+ raise ArgumentError, "interjection_id cannot be empty" if id.empty?
103
103
 
104
104
  batch_key = String(batch_key) unless batch_key.nil?
105
105
  metadata = metadata.to_h
@@ -111,14 +111,14 @@ module LittleGhost
111
111
  unless existing.message.to_h == message.to_h &&
112
112
  existing.batch_key == batch_key &&
113
113
  existing.metadata == metadata
114
- raise ArgumentError, "interruption_id has already been used with different input"
114
+ raise ArgumentError, "interjection_id has already been used with different input"
115
115
  end
116
116
 
117
117
  @waiters[existing] += 1
118
118
  return existing
119
119
  end
120
- if @tickets_by_id.length >= MAX_INTERRUPTION_COUNT
121
- raise AgentInterruptError, "Agent interruption capacity reached"
120
+ if @tickets_by_id.length >= MAX_INTERJECTION_COUNT
121
+ raise AgentInterjectionError, "Agent interjection capacity reached"
122
122
  end
123
123
 
124
124
  ticket = Ticket.new(message, id: id.freeze, batch_key: batch_key&.freeze, metadata:)
@@ -128,7 +128,7 @@ module LittleGhost
128
128
  ticket
129
129
  end
130
130
  rescue TypeError, NoMethodError
131
- raise ArgumentError, "interruption_id, batch_key, and metadata are invalid"
131
+ raise ArgumentError, "interjection_id, batch_key, and metadata are invalid"
132
132
  end
133
133
 
134
134
  def deliver
@@ -166,7 +166,7 @@ module LittleGhost
166
166
  return true if @closed_error
167
167
  return false if @delivered || !@queue.empty?
168
168
 
169
- @closed_error = AgentInterruptError.new("Agent is not currently running")
169
+ @closed_error = AgentInterjectionError.new("Agent is not currently running")
170
170
  true
171
171
  end
172
172
  end
@@ -0,0 +1,345 @@
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
+ # Named subclasses are the usual form. +to_builder+ creates a mutable dynamic
18
+ # definition seeded by the class, while +definition+ returns the immutable
19
+ # snapshot used for one execution. Composite results expose Assembly::Step
20
+ # records through RunResult#trajectory.
21
+ #
22
+ # A standalone assembly owns a top-level Run. An assembly built by a Runtime
23
+ # participates in the existing run and returns a RunResult. Applications
24
+ # normally subclass Agent, Workflow, Swarm, or Graph rather than Assembly
25
+ # directly. Standalone calls automatically reuse the active Configuration's
26
+ # shared Runtime while keeping each Run and its resources independent.
27
+ #
28
+ # == What each calling form returns
29
+ #
30
+ # [<tt>CustomerSupportAgent.ask(...)</tt>]
31
+ # A named class creates and returns a top-level Run.
32
+ # [<tt>CustomerSupportAgent.new(runtime: runtime).ask(...)</tt>]
33
+ # A standalone instance also creates and returns a top-level Run.
34
+ # [<tt>runtime.build_assembly(..., run: run).call(...)</tt>]
35
+ # A participant already bound to a Run returns its child RunResult.
36
+ # [<tt>stream_ask(...).each { |event| ... }</tt>]
37
+ # A standalone stream returns its top-level Run after enumeration. A
38
+ # run-scoped stream ends with an +invocation_stop+ event carrying RunResult.
39
+ class Assembly
40
+ extend Support::ClassAttributes
41
+
42
+ class_attribute :assembly_id_value
43
+ class_attribute :description_value
44
+
45
+ class << self
46
+ # Executes +message+ through a fresh standalone assembly and returns its Run.
47
+ #
48
+ # +options+ become Invocation fields. Common values include +history+,
49
+ # +context+, +settings+, +metadata+, +session_id+, +actor_id+, and
50
+ # +deadline_at+.
51
+ def ask(message, **options)
52
+ definition.implementation.new.ask(message, **options)
53
+ end
54
+
55
+ # Lazily streams +message+ through a fresh standalone assembly.
56
+ #
57
+ # Enumeration yields StreamEvent objects and returns the terminal Run.
58
+ # The same Invocation fields accepted by .ask may be supplied as +options+.
59
+ def stream_ask(message, **options)
60
+ snapshot = definition
61
+ stream = nil
62
+ Enumerator.new do |events|
63
+ stream ||= snapshot.implementation.new.stream_ask(message, **options)
64
+ stream.each { |event| events << event }
65
+ end
66
+ end
67
+
68
+ # Returns an immutable definition for this class.
69
+ def definition
70
+ if assembly_kind == :assembly
71
+ implementation = dup
72
+ implementation.assembly_id(assembly_id)
73
+ implementation.description(description)
74
+ implementation.freeze
75
+ return AssemblyDefinition.new(
76
+ kind: :assembly,
77
+ assembly_id:,
78
+ description:,
79
+ implementation:
80
+ )
81
+ end
82
+
83
+ to_builder.definition
84
+ end
85
+
86
+ # Returns a mutable dynamic builder seeded by this class.
87
+ def to_builder
88
+ builder_class = {
89
+ agent: AgentBuilder,
90
+ workflow: WorkflowBuilder,
91
+ swarm: SwarmBuilder,
92
+ graph: GraphBuilder
93
+ }.fetch(assembly_kind)
94
+ builder_class.new(base: self)
95
+ end
96
+
97
+ # :call-seq:
98
+ # assembly_id() -> String
99
+ # assembly_id(value) -> String
100
+ #
101
+ # The stable identifier used for tools and telemetry. Named subclasses
102
+ # derive it from their underscored class name without their type suffix.
103
+ def assembly_id(*values)
104
+ return assembly_id_value || default_assembly_id if values.empty?
105
+
106
+ self.assembly_id_value = values.fetch(0).to_s
107
+ end
108
+
109
+ # :call-seq:
110
+ # description() -> String
111
+ # description(value) -> String
112
+ #
113
+ # The human-readable description used when exposing the assembly as a tool.
114
+ def description(*values)
115
+ return description_value.to_s if values.empty?
116
+
117
+ self.description_value = values.fetch(0).to_s
118
+ end
119
+
120
+ # Returns +:agent+, +:workflow+, +:swarm+, +:graph+, or +:assembly+.
121
+ def assembly_kind
122
+ return :agent if defined?(Agent) && self <= Agent
123
+ return :workflow if defined?(Workflow) && self <= Workflow
124
+ return :swarm if defined?(Swarm) && self <= Swarm
125
+ return :graph if defined?(Graph) && self <= Graph
126
+
127
+ :assembly
128
+ end
129
+
130
+ private
131
+
132
+ def default_assembly_id
133
+ name = self.name.to_s.split("::").last.to_s.sub(/(Agent|Workflow|Swarm|Graph)\z/, "")
134
+ value = name.gsub(/([a-z\d])([A-Z])/, "\\1_\\2").downcase
135
+ (value.empty? ? assembly_kind.to_s : value).freeze
136
+ end
137
+ end
138
+
139
+ # The owning Run, or +nil+ for a standalone entrypoint.
140
+ attr_reader :run
141
+ # Runtime used to resolve participants and build Runs.
142
+ attr_reader :runtime
143
+ # Workspace supplied to this Assembly, when present.
144
+ attr_reader :workspace
145
+ # Sandbox supplied to this Assembly, when present.
146
+ attr_reader :sandbox
147
+
148
+ def initialize(run: nil, runtime: nil, workspace: nil, sandbox: nil, standalone: run.nil?) # :nodoc:
149
+ @run = run
150
+ @runtime = runtime || run&.runtime || LittleGhost.runtime
151
+ @workspace = workspace || (run.workspace if run&.respond_to?(:workspace))
152
+ @sandbox = sandbox || (run.sandbox if run&.respond_to?(:sandbox))
153
+ @standalone = standalone
154
+ @assembly_mutex = Mutex.new
155
+ @assembly_closed = false
156
+ @active_assemblies = []
157
+ end
158
+
159
+ # Builds the top-level Run used by a standalone assembly.
160
+ def build_run(payload) # :nodoc:
161
+ payload = payload.dup if payload.is_a?(Hash)
162
+ cancellation_token = if payload.is_a?(Hash)
163
+ payload.delete(:cancellation_token) || payload.delete("cancellation_token")
164
+ end
165
+ source_class = self.class.respond_to?(:assembly_source_class) ? self.class.assembly_source_class : self.class
166
+ options = {entrypoint_class: source_class}
167
+ options[:execution_class] = self.class unless source_class.equal?(self.class)
168
+ options[:agent_class] = source_class if is_a?(Agent)
169
+ options[:cancellation_token] = cancellation_token if cancellation_token
170
+ options[:workspace] = workspace if workspace
171
+ options[:sandbox] = sandbox if sandbox
172
+ runtime.build_run(payload, **options)
173
+ end
174
+
175
+ # Starts +payload+ on a supervised worker and returns an Execution.
176
+ def start_execution(payload, &event_consumer)
177
+ ensure_standalone!
178
+ Execution.start(build_run(payload), &event_consumer)
179
+ end
180
+
181
+ # Runs +input+ to completion.
182
+ #
183
+ # A standalone assembly returns a Run. A run-scoped assembly returns its
184
+ # RunResult.
185
+ def call(input = nil, **options)
186
+ return build_run(entrypoint_payload(input, options)).call if standalone?
187
+
188
+ result = nil
189
+ stream(input, **options).each do |event|
190
+ result = event.data[:result] if event.type == :invocation_stop
191
+ end
192
+ result
193
+ end
194
+
195
+ # Runs +message+ to completion.
196
+ #
197
+ # A standalone instance returns its owning Run. A run-scoped instance
198
+ # returns the child RunResult.
199
+ def ask(message, **options)
200
+ call(message, **options)
201
+ end
202
+
203
+ # Lazily streams +message+ through the standalone or run-scoped assembly.
204
+ #
205
+ # A standalone stream returns its terminal Run after enumeration. A
206
+ # run-scoped stream finishes with an +invocation_stop+ event containing its
207
+ # RunResult.
208
+ def stream_ask(message, **options)
209
+ if standalone?
210
+ options[:deadline_at] = options.delete(:deadline) if options.key?(:deadline)
211
+ stream = nil
212
+ return Enumerator.new do |events|
213
+ stream ||= build_run(entrypoint_payload(message, options)).each
214
+ stream.each { |event| events << event }
215
+ end
216
+ end
217
+
218
+ stream(message, **options)
219
+ end
220
+
221
+ # Exposes this assembly as a Tool instance.
222
+ #
223
+ # By default, calls do not remember earlier conversation history. Set
224
+ # <tt>preserve_context: true</tt> to carry that history from one tool call to
225
+ # the next. This option does not control working state: every call receives
226
+ # the invoking Tool's current RunContext#state, which may include current
227
+ # request values or values restored from a Session. Nested tools must still
228
+ # authorize privileged work with current, application-established values.
229
+ def as_tool(name: self.class.assembly_id, description: self.class.description, preserve_context: false)
230
+ assembly = self
231
+ description = "Delegate a task to #{name}." if description.to_s.empty?
232
+ mutex = Mutex.new
233
+ retained_history = []
234
+ tool_class = Tool.define(
235
+ name:,
236
+ description:,
237
+ input_schema: {
238
+ type: "object",
239
+ properties: {input: {type: "string"}},
240
+ required: ["input"],
241
+ additionalProperties: false
242
+ }
243
+ ) do |input, context: nil|
244
+ invocation = lambda do
245
+ target = if assembly.is_a?(Agent)
246
+ assembly
247
+ elsif assembly.run
248
+ assembly.runtime.build_assembly(assembly.class, run: assembly.run)
249
+ else
250
+ assembly.class.new(runtime: assembly.runtime)
251
+ end
252
+ options = {
253
+ history: preserve_context ? retained_history : [],
254
+ context: context&.state || {},
255
+ cancellation_token: context&.cancellation_token || Support::CancellationToken.new,
256
+ deadline: context&.deadline,
257
+ parent_operation_id: assembly.run&.operation_id
258
+ }
259
+ if target.is_a?(Agent)
260
+ options[:interjection_metadata] = context&.interjection_metadata
261
+ options[:interjection_ids] = context&.interjection_ids || []
262
+ end
263
+ result = target.call(input.fetch("input"), **options)
264
+ if result.is_a?(Run)
265
+ raise result.error if result.error
266
+
267
+ result = result.result
268
+ end
269
+ raise ProtocolError, "assembly tool invocation did not return a result" unless result
270
+
271
+ retained_history.replace(result.messages.reject { |message| message.role == :system }) if preserve_context
272
+ result.structured? ? result.structured_result.value : result.text
273
+ ensure
274
+ target&.close unless target.equal?(assembly)
275
+ end
276
+ preserve_context ? mutex.synchronize(&invocation) : invocation.call
277
+ end
278
+ tool_class.define_method(:close) { assembly.close }
279
+ tool_class.new(binding: Tool::Binding.new(
280
+ agent: (self if is_a?(Agent)),
281
+ run:,
282
+ runtime:,
283
+ model: (model if respond_to?(:model)),
284
+ workspace:,
285
+ sandbox:
286
+ ))
287
+ end
288
+
289
+ # Adds an interjection to the single active leaf Agent.
290
+ def interject(message, **options)
291
+ child = @assembly_mutex.synchronize do
292
+ active = @active_assemblies.dup
293
+ if active.empty?
294
+ raise AgentInterjectionError, "Assembly is not currently running"
295
+ end
296
+ if active.length > 1
297
+ raise AgentInterjectionError, "Assembly has multiple active participants; the interjection target is ambiguous"
298
+ end
299
+ active.first
300
+ end
301
+ child.interject(message, **options)
302
+ end
303
+
304
+ # Closes resources owned directly by this assembly.
305
+ def close
306
+ @assembly_mutex.synchronize do
307
+ return if @assembly_closed
308
+
309
+ @assembly_closed = true
310
+ end
311
+ end
312
+
313
+ def entrypoint_name = self.class.assembly_id # :nodoc:
314
+
315
+ # Additional prompt locals made available to child agents.
316
+ def prompt_locals = {}
317
+
318
+ protected
319
+
320
+ def standalone? = @standalone # :nodoc:
321
+
322
+ def with_active_assembly(assembly) # :nodoc:
323
+ @assembly_mutex.synchronize do
324
+ raise Error, "assembly is already closed" if @assembly_closed
325
+ @active_assemblies << assembly
326
+ end
327
+ yield
328
+ ensure
329
+ @assembly_mutex.synchronize { @active_assemblies.delete(assembly) } if assembly
330
+ end
331
+
332
+ def entrypoint_payload(input, options) # :nodoc:
333
+ return options if input.nil?
334
+ return input.merge(options) if input.is_a?(Hash)
335
+
336
+ {message: input, **options}
337
+ end
338
+
339
+ private
340
+
341
+ def ensure_standalone!
342
+ raise Error, "Only a standalone assembly can start an execution" unless standalone?
343
+ end
344
+ end
345
+ end