Class: LittleGhost::Assembly
- Inherits:
-
Object
- Object
- LittleGhost::Assembly
- Extended by:
- Support::ClassAttributes
- Defined in:
- lib/little_ghost/assembly.rb,
lib/little_ghost/assembly_execution.rb
Overview
Gives one agent or a coordinated group the same callable entrypoint.
An assembly is anything callers can invoke like one Agent. An Agent is the
smallest assembly because it owns one model loop. Workflow, Swarm, and Graph
subclasses coordinate several participants while preserving the same
ask, stream_ask, call, and stream interface.
agent_run = CustomerSupportAgent.ask("Why is my transfer pending?")
graph_run = SupportFlowGraph.ask("Why is my transfer pending?")
agent_run.response
graph_run.response
A standalone assembly owns a top-level Run. An assembly built by a Runtime participates in the existing run and returns a RunResult. Applications normally subclass Agent, Workflow, Swarm, or Graph rather than Assembly directly.
Defined Under Namespace
Classes: Attempt, Step, StepExecution, Trajectory
Constant Summary collapse
- MAX_STEP_OUTPUT_BYTES =
:nodoc:
64 * 1024
- MAX_STEP_EVENTS =
:nodoc:
10_000- MAX_STEP_EVENT_BYTES =
:nodoc:
10 * 1024 * 1024
Instance Attribute Summary collapse
-
#run ⇒ Object
readonly
The owning run, runtime, and optional standalone resources.
-
#runtime ⇒ Object
readonly
The owning run, runtime, and optional standalone resources.
-
#sandbox ⇒ Object
readonly
The owning run, runtime, and optional standalone resources.
-
#workspace ⇒ Object
readonly
The owning run, runtime, and optional standalone resources.
Class Method Summary collapse
-
.ask(message, **options) ⇒ Object
Executes
messagethrough a fresh standalone assembly and returns its Run. -
.assembly_id(*values) ⇒ Object
:call-seq: assembly_id() -> String assembly_id(value) -> String.
-
.assembly_kind ⇒ Object
Returns
:agent,:workflow,:swarm,:graph, or:assembly. -
.definition ⇒ Object
Returns an immutable definition for this class.
-
.description(*values) ⇒ Object
:call-seq: description() -> String description(value) -> String.
-
.stream_ask(message, **options) ⇒ Object
Lazily streams
messagethrough a fresh standalone assembly. -
.to_builder ⇒ Object
Returns a mutable dynamic builder seeded by this class.
-
.validate_step_policy!(values) ⇒ Object
:nodoc:.
Instance Method Summary collapse
-
#as_tool(name: self.class.assembly_id, description: self.class.description, preserve_context: false) ⇒ Object
Exposes this assembly as a Tool instance.
-
#ask(message, **options) ⇒ Object
Runs
messageto completion. -
#build_run(payload) ⇒ Object
Builds the top-level Run used by a standalone assembly.
-
#call(input = nil, **options) ⇒ Object
Runs
inputto completion. -
#close ⇒ Object
Closes resources owned directly by this assembly.
-
#entrypoint_name ⇒ Object
:nodoc:.
-
#initialize(run: nil, runtime: nil, workspace: nil, sandbox: nil, standalone: run.nil?) ⇒ Assembly
constructor
:nodoc:.
-
#interrupt(message, **options) ⇒ Object
Adds
messageto the single active leaf Agent and returns its text reply. -
#interrupt_response(message, **options) ⇒ Object
Adds an interruption to the single active leaf Agent.
-
#prompt_locals ⇒ Object
Additional prompt locals made available to child agents.
-
#start_execution(payload, &event_consumer) ⇒ Object
Starts
payloadon a supervised worker and returns an Execution. -
#stream_ask(message, **options) ⇒ Object
Lazily streams
messagethrough the standalone or run-scoped assembly.
Methods included from Support::ClassAttributes
Constructor Details
#initialize(run: nil, runtime: nil, workspace: nil, sandbox: nil, standalone: run.nil?) ⇒ Assembly
:nodoc:
117 118 119 120 121 122 123 124 125 126 |
# File 'lib/little_ghost/assembly.rb', line 117 def initialize(run: nil, runtime: nil, workspace: nil, sandbox: nil, standalone: run.nil?) # :nodoc: @run = run @runtime = runtime || run&.runtime || Runtime.new(configuration: LittleGhost.configuration) @workspace = workspace || (run.workspace if run&.respond_to?(:workspace)) @sandbox = sandbox || (run.sandbox if run&.respond_to?(:sandbox)) @standalone = standalone @assembly_mutex = Mutex.new @assembly_closed = false @active_assemblies = [] end |
Instance Attribute Details
#run ⇒ Object (readonly)
The owning run, runtime, and optional standalone resources.
115 116 117 |
# File 'lib/little_ghost/assembly.rb', line 115 def run @run end |
#runtime ⇒ Object (readonly)
The owning run, runtime, and optional standalone resources.
115 116 117 |
# File 'lib/little_ghost/assembly.rb', line 115 def runtime @runtime end |
#sandbox ⇒ Object (readonly)
The owning run, runtime, and optional standalone resources.
115 116 117 |
# File 'lib/little_ghost/assembly.rb', line 115 def sandbox @sandbox end |
#workspace ⇒ Object (readonly)
The owning run, runtime, and optional standalone resources.
115 116 117 |
# File 'lib/little_ghost/assembly.rb', line 115 def workspace @workspace end |
Class Method Details
.ask(message, **options) ⇒ Object
Executes message through a fresh standalone assembly and returns its Run.
29 30 31 |
# File 'lib/little_ghost/assembly.rb', line 29 def ask(, **) definition.implementation.new.ask(, **) end |
.assembly_id(*values) ⇒ Object
:call-seq:
assembly_id() -> String
assembly_id(value) -> String
The stable identifier used for tools and telemetry. Named subclasses derive it from their underscored class name without their type suffix.
78 79 80 81 82 |
# File 'lib/little_ghost/assembly.rb', line 78 def assembly_id(*values) return assembly_id_value || default_assembly_id if values.empty? self.assembly_id_value = values.fetch(0).to_s end |
.assembly_kind ⇒ Object
Returns :agent, :workflow, :swarm, :graph, or :assembly.
96 97 98 99 100 101 102 103 |
# File 'lib/little_ghost/assembly.rb', line 96 def assembly_kind return :agent if defined?(Agent) && self <= Agent return :workflow if defined?(Workflow) && self <= Workflow return :swarm if defined?(Swarm) && self <= Swarm return :graph if defined?(Graph) && self <= Graph :assembly end |
.definition ⇒ Object
Returns an immutable definition for this class.
44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 |
# File 'lib/little_ghost/assembly.rb', line 44 def definition if assembly_kind == :assembly implementation = dup implementation.assembly_id(assembly_id) implementation.description(description) implementation.freeze return AssemblyDefinition.new( kind: :assembly, assembly_id:, description:, implementation: ) end to_builder.definition end |
.description(*values) ⇒ Object
:call-seq:
description() -> String
description(value) -> String
The human-readable description used when exposing the assembly as a tool.
89 90 91 92 93 |
# File 'lib/little_ghost/assembly.rb', line 89 def description(*values) return description_value.to_s if values.empty? self.description_value = values.fetch(0).to_s end |
.stream_ask(message, **options) ⇒ Object
Lazily streams message through a fresh standalone assembly.
34 35 36 37 38 39 40 41 |
# File 'lib/little_ghost/assembly.rb', line 34 def stream_ask(, **) snapshot = definition stream = nil Enumerator.new do |events| stream ||= snapshot.implementation.new.stream_ask(, **) stream.each { |event| events << event } end end |
.to_builder ⇒ Object
Returns a mutable dynamic builder seeded by this class.
62 63 64 65 66 67 68 69 70 |
# File 'lib/little_ghost/assembly.rb', line 62 def to_builder builder_class = { agent: AgentBuilder, workflow: WorkflowBuilder, swarm: SwarmBuilder, graph: GraphBuilder }.fetch(assembly_kind) builder_class.new(base: self) end |
.validate_step_policy!(values) ⇒ Object
:nodoc:
254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 |
# File 'lib/little_ghost/assembly_execution.rb', line 254 def validate_step_policy!(values) # :nodoc: values = values.compact retries = Integer(values.fetch(:retries, 0)) raise ArgumentError, "retries must be at least 0" if retries.negative? retry_on = Array(values[:retry_on]) if retries.positive? && retry_on.empty? raise ArgumentError, "retry_on is required when retries is greater than 0" end unless retry_on.all? { |error| error.is_a?(Class) && error <= Exception } raise ArgumentError, "retry_on must contain exception classes" end timeout = Float(values[:timeout]) if values[:timeout] retry_delay = Float(values.fetch(:retry_delay, 0)) raise ArgumentError, "timeout must be positive" if timeout && (!timeout.positive? || !timeout.finite?) raise ArgumentError, "retry_delay must be non-negative" if retry_delay.negative? || !retry_delay.finite? {retries:, retry_on: retry_on.freeze, timeout:, retry_delay:}.freeze end |
Instance Method Details
#as_tool(name: self.class.assembly_id, description: self.class.description, preserve_context: false) ⇒ Object
Exposes this assembly as a Tool instance.
By default each call has empty conversational history. Set
preserve_context: true to retain history serially. Every call still
receives the invoking Tool::Context application state; preserve_context
does not suppress it. Tools inside the assembly remain responsible for
authorizing privileged work from trusted context.
190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 |
# File 'lib/little_ghost/assembly.rb', line 190 def as_tool(name: self.class.assembly_id, description: self.class.description, preserve_context: false) assembly = self description = "Delegate a task to #{name}." if description.to_s.empty? mutex = Mutex.new retained_history = [] tool_class = Tool.define( name:, description:, input_schema: { type: "object", properties: {input: {type: "string"}}, required: ["input"], additionalProperties: false } ) do |input, context: nil| invocation = lambda do target = if assembly.is_a?(Agent) assembly elsif assembly.run assembly.runtime.build_assembly(assembly.class, run: assembly.run) else assembly.class.new(runtime: assembly.runtime) end = { history: preserve_context ? retained_history : [], context: context&.state || {}, cancellation_token: context&.cancellation_token || Support::CancellationToken.new, deadline: context&.deadline, parent_operation_id: assembly.run&.operation_id } if target.is_a?(Agent) [:interruption_metadata] = context&. [:interruption_ids] = context&.interruption_ids || [] end result = target.call(input.fetch("input"), **) if result.is_a?(Run) raise result.error if result.error result = result.result end raise ProtocolError, "assembly tool invocation did not return a result" unless result retained_history.replace(result..reject { || .role == :system }) if preserve_context result.structured? ? result.structured_result.value : result.text ensure target&.close unless target.equal?(assembly) end preserve_context ? mutex.synchronize(&invocation) : invocation.call end tool_class.define_method(:close) { assembly.close } tool_class.new(binding: Tool::Binding.new( agent: (self if is_a?(Agent)), run:, runtime:, model: (model if respond_to?(:model)), workspace:, sandbox: )) end |
#ask(message, **options) ⇒ Object
Runs message to completion.
165 166 167 |
# File 'lib/little_ghost/assembly.rb', line 165 def ask(, **) call(, **) end |
#build_run(payload) ⇒ Object
Builds the top-level Run used by a standalone assembly.
129 130 131 132 133 134 135 136 137 138 139 140 141 142 |
# File 'lib/little_ghost/assembly.rb', line 129 def build_run(payload) # :nodoc: payload = payload.dup if payload.is_a?(Hash) cancellation_token = if payload.is_a?(Hash) payload.delete(:cancellation_token) || payload.delete("cancellation_token") end source_class = self.class.respond_to?(:assembly_source_class) ? self.class.assembly_source_class : self.class = {entrypoint_class: source_class} [:execution_class] = self.class unless source_class.equal?(self.class) [:agent_class] = source_class if is_a?(Agent) [:cancellation_token] = cancellation_token if cancellation_token [:workspace] = workspace if workspace [:sandbox] = sandbox if sandbox runtime.build_run(payload, **) end |
#call(input = nil, **options) ⇒ Object
Runs input to completion.
A standalone assembly returns a Run. A run-scoped assembly returns its RunResult.
154 155 156 157 158 159 160 161 162 |
# File 'lib/little_ghost/assembly.rb', line 154 def call(input = nil, **) return build_run(entrypoint_payload(input, )).call if standalone? result = nil stream(input, **).each do |event| result = event.data[:result] if event.type == :invocation_stop end result end |
#close ⇒ Object
Closes resources owned directly by this assembly.
271 272 273 274 275 276 277 |
# File 'lib/little_ghost/assembly.rb', line 271 def close @assembly_mutex.synchronize do return if @assembly_closed @assembly_closed = true end end |
#entrypoint_name ⇒ Object
:nodoc:
279 |
# File 'lib/little_ghost/assembly.rb', line 279 def entrypoint_name = self.class.assembly_id # :nodoc: |
#interrupt(message, **options) ⇒ Object
Adds message to the single active leaf Agent and returns its text reply.
251 252 253 |
# File 'lib/little_ghost/assembly.rb', line 251 def interrupt(, **) interrupt_response(, **).text end |
#interrupt_response(message, **options) ⇒ Object
Adds an interruption to the single active leaf Agent.
256 257 258 259 260 261 262 263 264 265 266 267 268 |
# File 'lib/little_ghost/assembly.rb', line 256 def interrupt_response(, **) child = @assembly_mutex.synchronize do active = @active_assemblies.dup if active.empty? raise AgentInterruptError, "Assembly is not currently running" end if active.length > 1 raise AgentInterruptError, "Assembly has multiple active participants; the interruption target is ambiguous" end active.first end child.interrupt_response(, **) end |
#prompt_locals ⇒ Object
Additional prompt locals made available to child agents.
282 |
# File 'lib/little_ghost/assembly.rb', line 282 def prompt_locals = {} |
#start_execution(payload, &event_consumer) ⇒ Object
Starts payload on a supervised worker and returns an Execution.
145 146 147 148 |
# File 'lib/little_ghost/assembly.rb', line 145 def start_execution(payload, &event_consumer) ensure_standalone! Execution.start(build_run(payload), &event_consumer) end |
#stream_ask(message, **options) ⇒ Object
Lazily streams message through the standalone or run-scoped assembly.
170 171 172 173 174 175 176 177 178 179 180 181 |
# File 'lib/little_ghost/assembly.rb', line 170 def stream_ask(, **) if standalone? [:deadline_at] = .delete(:deadline) if .key?(:deadline) stream = nil return Enumerator.new do |events| stream ||= build_run(entrypoint_payload(, )).each stream.each { |event| events << event } end end stream(, **) end |