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.
- checksums.yaml +4 -4
- data/README.md +72 -74
- data/docs/guides/assemblies.md +286 -0
- data/docs/guides/core_concepts.md +159 -135
- data/docs/guides/getting_started.md +114 -83
- data/docs/guides/production.md +187 -0
- data/docs/guides/prompt_views.md +132 -0
- data/lib/little_ghost/ag_ui/adapter.rb +3 -3
- data/lib/little_ghost/agent/delegation.rb +35 -8
- data/lib/little_ghost/agent/tool_loop.rb +2 -1
- data/lib/little_ghost/agent.rb +280 -326
- data/lib/little_ghost/agent_builder.rb +20 -4
- data/lib/little_ghost/agent_factory.rb +3 -0
- data/lib/little_ghost/{agent_interruptions.rb → agent_interjections.rb} +12 -12
- data/lib/little_ghost/assembly.rb +345 -0
- data/lib/little_ghost/assembly_builder.rb +497 -0
- data/lib/little_ghost/assembly_execution.rb +535 -0
- data/lib/little_ghost/configuration.rb +263 -39
- data/lib/little_ghost/content.rb +5 -5
- data/lib/little_ghost/data_map.rb +209 -0
- data/lib/little_ghost/errors.rb +10 -2
- data/lib/little_ghost/execution.rb +206 -0
- data/lib/little_ghost/graph.rb +930 -0
- data/lib/little_ghost/message.rb +4 -4
- data/lib/little_ghost/model_resolver.rb +2 -2
- data/lib/little_ghost/prompt_resolver.rb +2 -0
- data/lib/little_ghost/run.rb +190 -64
- data/lib/little_ghost/run_context.rb +33 -20
- data/lib/little_ghost/run_result.rb +22 -11
- data/lib/little_ghost/runtime/hook.rb +9 -4
- data/lib/little_ghost/runtime.rb +134 -36
- data/lib/little_ghost/sandbox.rb +1 -1
- data/lib/little_ghost/session.rb +12 -23
- data/lib/little_ghost/session_store.rb +9 -5
- data/lib/little_ghost/session_stores/agent_core_memory.rb +64 -56
- data/lib/little_ghost/session_stores/filesystem.rb +261 -0
- data/lib/little_ghost/session_stores/memory.rb +7 -0
- data/lib/little_ghost/subagents/manager.rb +42 -42
- 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 +439 -0
- data/lib/little_ghost/tool.rb +88 -20
- data/lib/little_ghost/tools/write_todos.rb +6 -1
- data/lib/little_ghost/tracing/open_telemetry.rb +14 -3
- data/lib/little_ghost/unrestricted_sandbox.rb +1 -1
- data/lib/little_ghost/version.rb +1 -1
- data/lib/little_ghost/workflow.rb +224 -90
- data/lib/little_ghost.rb +36 -25
- metadata +17 -5
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module LittleGhost
|
|
4
|
-
class
|
|
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.
|
|
96
|
-
child =
|
|
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
|
-
|
|
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),
|
|
@@ -3,16 +3,16 @@
|
|
|
3
3
|
require "securerandom"
|
|
4
4
|
|
|
5
5
|
module LittleGhost
|
|
6
|
-
class
|
|
6
|
+
class AgentInterjections # :nodoc: all
|
|
7
7
|
MAX_BATCH_SIZE = 100
|
|
8
|
-
|
|
8
|
+
MAX_INTERJECTION_COUNT = 1_000
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
def initialize(text:, tool_calls:,
|
|
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
|
-
|
|
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
|
|
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, "
|
|
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, "
|
|
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 >=
|
|
121
|
-
raise
|
|
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, "
|
|
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 =
|
|
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
|