little_ghost 0.1.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 +7 -0
- data/LICENSE.txt +22 -0
- data/README.md +122 -0
- data/docs/guides/Core Concepts.md +203 -0
- data/docs/guides/Getting Started.md +187 -0
- data/lib/little_ghost/ag_ui/adapter.rb +194 -0
- data/lib/little_ghost/ag_ui.rb +5 -0
- data/lib/little_ghost/agent/context_management.rb +285 -0
- data/lib/little_ghost/agent/delegation.rb +128 -0
- data/lib/little_ghost/agent/skills.rb +96 -0
- data/lib/little_ghost/agent/tool_loop.rb +239 -0
- data/lib/little_ghost/agent.rb +2111 -0
- data/lib/little_ghost/agent_builder.rb +191 -0
- data/lib/little_ghost/agent_interruptions.rb +197 -0
- data/lib/little_ghost/configuration.rb +337 -0
- data/lib/little_ghost/content.rb +324 -0
- data/lib/little_ghost/default_model_registry.rb +71 -0
- data/lib/little_ghost/errors.rb +48 -0
- data/lib/little_ghost/events.rb +264 -0
- data/lib/little_ghost/execution_state.rb +58 -0
- data/lib/little_ghost/instrumentation.rb +475 -0
- data/lib/little_ghost/invocation.rb +285 -0
- data/lib/little_ghost/lookup.rb +37 -0
- data/lib/little_ghost/mcp/client.rb +396 -0
- data/lib/little_ghost/mcp.rb +5 -0
- data/lib/little_ghost/message.rb +75 -0
- data/lib/little_ghost/model.rb +88 -0
- data/lib/little_ghost/model_capabilities.rb +126 -0
- data/lib/little_ghost/model_registry.rb +173 -0
- data/lib/little_ghost/model_request.rb +107 -0
- data/lib/little_ghost/model_response.rb +48 -0
- data/lib/little_ghost/path_set.rb +32 -0
- data/lib/little_ghost/prompt_resolver.rb +251 -0
- data/lib/little_ghost/providers/bedrock.rb +506 -0
- data/lib/little_ghost/providers/http_transport.rb +149 -0
- data/lib/little_ghost/providers/open_router.rb +171 -0
- data/lib/little_ghost/providers/openai.rb +27 -0
- data/lib/little_ghost/providers/openai_compatible.rb +745 -0
- data/lib/little_ghost/providers/sse_parser.rb +35 -0
- data/lib/little_ghost/run.rb +607 -0
- data/lib/little_ghost/run_context.rb +129 -0
- data/lib/little_ghost/run_result.rb +111 -0
- data/lib/little_ghost/runtime/hook.rb +31 -0
- data/lib/little_ghost/runtime.rb +392 -0
- data/lib/little_ghost/sandbox.rb +138 -0
- data/lib/little_ghost/session.rb +229 -0
- data/lib/little_ghost/session_store.rb +96 -0
- data/lib/little_ghost/session_stores/agent_core_memory.rb +1086 -0
- data/lib/little_ghost/session_stores/memory.rb +86 -0
- data/lib/little_ghost/skills/catalog.rb +283 -0
- data/lib/little_ghost/skills/skill.rb +60 -0
- data/lib/little_ghost/skills.rb +4 -0
- data/lib/little_ghost/stream_event.rb +49 -0
- data/lib/little_ghost/structured_output.rb +126 -0
- data/lib/little_ghost/subagents/agent_path.rb +63 -0
- data/lib/little_ghost/subagents/definition.rb +42 -0
- data/lib/little_ghost/subagents/manager.rb +1615 -0
- data/lib/little_ghost/support/callbacks.rb +151 -0
- data/lib/little_ghost/support/cancellation_token.rb +86 -0
- data/lib/little_ghost/support/class_attributes.rb +40 -0
- data/lib/little_ghost/support/content_capture.rb +150 -0
- data/lib/little_ghost/support/executor.rb +75 -0
- data/lib/little_ghost/support/interruptible_stream.rb +103 -0
- data/lib/little_ghost/support/loader.rb +263 -0
- data/lib/little_ghost/support/output_truncation.rb +71 -0
- data/lib/little_ghost/support/redactor.rb +66 -0
- data/lib/little_ghost/support.rb +34 -0
- data/lib/little_ghost/tool.rb +448 -0
- data/lib/little_ghost/tool_execution.rb +59 -0
- data/lib/little_ghost/tool_registry.rb +156 -0
- data/lib/little_ghost/tools/filesystem.rb +119 -0
- data/lib/little_ghost/tools/shell.rb +45 -0
- data/lib/little_ghost/tools/write_todos.rb +91 -0
- data/lib/little_ghost/tools.rb +6 -0
- data/lib/little_ghost/tracing/open_telemetry.rb +517 -0
- data/lib/little_ghost/unrestricted_sandbox.rb +306 -0
- data/lib/little_ghost/usage.rb +47 -0
- data/lib/little_ghost/version.rb +6 -0
- data/lib/little_ghost/workflow.rb +351 -0
- data/lib/little_ghost/workspace.rb +31 -0
- data/lib/little_ghost.rb +120 -0
- metadata +225 -0
|
@@ -0,0 +1,2111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require_relative "support/output_truncation"
|
|
5
|
+
require_relative "tool_execution"
|
|
6
|
+
|
|
7
|
+
module LittleGhost
|
|
8
|
+
# Define reusable agents that can answer, stream, call tools, and delegate work.
|
|
9
|
+
# Each subclass describes one application role with an inheritable Ruby DSL.
|
|
10
|
+
#
|
|
11
|
+
# A customer support agent can look up an account itself and give longer investigations
|
|
12
|
+
# to a research specialist:
|
|
13
|
+
#
|
|
14
|
+
# class ResearchAgent < LittleGhost::Agent
|
|
15
|
+
# description "Researches transfer failures"
|
|
16
|
+
# model "customer_support.research"
|
|
17
|
+
# tools LedgerSearchTool
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# class CustomerSupportAgent < LittleGhost::Agent
|
|
21
|
+
# description "Handles support requests"
|
|
22
|
+
# model :customer_support
|
|
23
|
+
# limits max_turns: 40
|
|
24
|
+
# tools AccountLookupTool
|
|
25
|
+
# subagent ResearchAgent, kind: "research"
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# run = CustomerSupportAgent.ask("Why is transfer 481 pending?")
|
|
29
|
+
# run.completed? # => true
|
|
30
|
+
# run.response # => "Transfer 481 is waiting for the receiving bank."
|
|
31
|
+
#
|
|
32
|
+
# Class declarations are inherited. Prompts resolve by the agent's logical
|
|
33
|
+
# path unless +system_prompt+ or +system_template+ supplies one explicitly;
|
|
34
|
+
# tools and prompt locals may also be selected dynamically for each run.
|
|
35
|
+
# Capabilities such as skills, context management, loop detection, and
|
|
36
|
+
# delegation remain inactive until their DSL methods are called.
|
|
37
|
+
#
|
|
38
|
+
# The class-level +ask+ helper creates a standalone entrypoint and returns a
|
|
39
|
+
# completed Run. Create a standalone instance explicitly to reuse one Runtime
|
|
40
|
+
# or call +stream_ask+ for StreamEvent objects. Runtimes build bound instances
|
|
41
|
+
# internally; their +call+ method returns a RunResult and their +stream+ method
|
|
42
|
+
# follows the owning run's single-execution lifecycle. Closing an agent closes
|
|
43
|
+
# owned tools and any standalone workspace and sandbox.
|
|
44
|
+
# LittleGhost::Agent.ask uses <tt>You are a helpful agent.</tt> as its system
|
|
45
|
+
# prompt. Subclasses continue to use their inline or conventional prompts.
|
|
46
|
+
#
|
|
47
|
+
# Models can return ordinary text or a locally validated structured result.
|
|
48
|
+
# Tool failures are sanitized before returning to the model, diagnostic
|
|
49
|
+
# capture can be disabled for sensitive agents, and cancellation, deadlines,
|
|
50
|
+
# and cleanup failures remain framework control flow.
|
|
51
|
+
class Agent
|
|
52
|
+
DEFAULT_SYSTEM_PROMPT = "You are a helpful agent." # :nodoc:
|
|
53
|
+
DEFAULT_MAX_TOOL_RESULT_TOKENS = 10_000 # :nodoc:
|
|
54
|
+
MAX_STRUCTURED_RESULT_BYTES = 1_000_000 # :nodoc:
|
|
55
|
+
MAX_STRUCTURED_RESULT_DEPTH = 64 # :nodoc:
|
|
56
|
+
MAX_STRUCTURED_RESULT_NODES = 100_000 # :nodoc:
|
|
57
|
+
RESULT_SCHEMA_KEYWORDS = %w[
|
|
58
|
+
$schema title description type enum minimum maximum minLength maxLength
|
|
59
|
+
properties required additionalProperties minItems maxItems items
|
|
60
|
+
].freeze # :nodoc:
|
|
61
|
+
CALLBACKS = %i[
|
|
62
|
+
after_initialize
|
|
63
|
+
before_invocation after_invocation
|
|
64
|
+
before_model after_model after_model_error
|
|
65
|
+
before_tool after_tool
|
|
66
|
+
].freeze # :nodoc:
|
|
67
|
+
|
|
68
|
+
extend Support::ClassAttributes
|
|
69
|
+
|
|
70
|
+
class_attribute :agent_id_value
|
|
71
|
+
class_attribute :description_value
|
|
72
|
+
class_attribute :model_value
|
|
73
|
+
class_attribute :limits_value, default: {}
|
|
74
|
+
class_attribute :result_schema_value
|
|
75
|
+
class_attribute :capture_diagnostics_value, default: true
|
|
76
|
+
class_attribute :system_template_value
|
|
77
|
+
class_attribute :system_prompt_value
|
|
78
|
+
class_attribute :system_prompt_builder_value
|
|
79
|
+
class_attribute :tool_declarations_value, default: []
|
|
80
|
+
class_attribute :prompt_local_values, default: {}
|
|
81
|
+
class_attribute :callback_values, default: Support::Callbacks.new(*CALLBACKS)
|
|
82
|
+
|
|
83
|
+
class << self
|
|
84
|
+
# Executes +message+ through a fresh standalone entrypoint and returns the
|
|
85
|
+
# completed LittleGhost::Run. Invocation +options+ are forwarded to #ask.
|
|
86
|
+
#
|
|
87
|
+
# Create an instance explicitly when reusing a Runtime or streaming events.
|
|
88
|
+
def ask(message, **options)
|
|
89
|
+
new.ask(message, **options)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# :call-seq:
|
|
93
|
+
# agent_id() -> String
|
|
94
|
+
# agent_id(value) -> String
|
|
95
|
+
#
|
|
96
|
+
# The stable identifier used in telemetry, delegation, and default tool names.
|
|
97
|
+
# Named subclasses derive it from their underscored class name without an
|
|
98
|
+
# +Agent+ suffix; passing +value+ replaces that default.
|
|
99
|
+
def agent_id(*values)
|
|
100
|
+
return agent_id_value || default_agent_id if values.empty?
|
|
101
|
+
|
|
102
|
+
self.agent_id_value = values.fetch(0).to_s
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The underscored, namespace-aware path used for conventional prompt lookup.
|
|
106
|
+
def logical_path
|
|
107
|
+
parts = name.to_s.split("::")
|
|
108
|
+
parts[-1] = parts.last.sub(/Agent\z/, "") if parts.any?
|
|
109
|
+
parts.reject(&:empty?).map { |part| underscore(part) }.join("/")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# :call-seq:
|
|
113
|
+
# description() -> String
|
|
114
|
+
# description(value) -> String
|
|
115
|
+
#
|
|
116
|
+
# The human-readable description shown when this agent is delegated.
|
|
117
|
+
def description(*values)
|
|
118
|
+
return description_value.to_s if values.empty?
|
|
119
|
+
|
|
120
|
+
self.description_value = values.fetch(0).to_s
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# :call-seq:
|
|
124
|
+
# model() -> String, Proc, nil
|
|
125
|
+
# model(role) -> String
|
|
126
|
+
# model { |invocation| ... } -> Proc
|
|
127
|
+
#
|
|
128
|
+
# The logical model role for this agent.
|
|
129
|
+
#
|
|
130
|
+
# Pass a block to choose a role from each Invocation at run time.
|
|
131
|
+
def model(*values, &block)
|
|
132
|
+
return model_value if values.empty? && !block
|
|
133
|
+
|
|
134
|
+
self.model_value = block || values.fetch(0).to_s
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def model_role(invocation) # :nodoc:
|
|
138
|
+
value = model_value
|
|
139
|
+
resolved = value.respond_to?(:call) ? value.call(invocation) : value
|
|
140
|
+
resolved&.to_s
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# :call-seq:
|
|
144
|
+
# limits() -> Hash
|
|
145
|
+
# limits(**values) -> Hash
|
|
146
|
+
#
|
|
147
|
+
# Inherited execution limits for model turns, tool calls, and tool output.
|
|
148
|
+
#
|
|
149
|
+
# Keyword arguments merge into the current limits and the zero-argument
|
|
150
|
+
# form returns them.
|
|
151
|
+
def limits(**values)
|
|
152
|
+
return limits_value if values.empty?
|
|
153
|
+
|
|
154
|
+
self.limits_value = limits.merge(values.transform_keys(&:to_sym))
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# :call-seq:
|
|
158
|
+
# result_schema() -> Hash, nil
|
|
159
|
+
# result_schema(schema, name: nil, description: nil, strategy: :auto) -> Hash
|
|
160
|
+
# result_schema(name: nil, description: nil, strategy: :auto, **schema) -> Hash
|
|
161
|
+
#
|
|
162
|
+
# Declares a strict JSON-object result contract. Every object must set
|
|
163
|
+
# <tt>additionalProperties: false</tt> and require each property. Automatic
|
|
164
|
+
# strategy selection prefers provider-native structured output and falls
|
|
165
|
+
# back to a terminal tool when supported.
|
|
166
|
+
#
|
|
167
|
+
# A missing or invalid result receives one repair attempt before
|
|
168
|
+
# LittleGhost::StructuredResultError is raised. Invalid schemas and
|
|
169
|
+
# strategies raise LittleGhost::ConfigurationError immediately.
|
|
170
|
+
def result_schema(schema = nil, name: nil, description: nil, strategy: :auto, **schema_keywords)
|
|
171
|
+
return result_schema_value if schema.nil? && schema_keywords.empty? && name.nil? && description.nil? && strategy == :auto
|
|
172
|
+
|
|
173
|
+
if schema.nil?
|
|
174
|
+
schema = schema_keywords
|
|
175
|
+
elsif !schema_keywords.empty?
|
|
176
|
+
raise ArgumentError, "Provide result_schema as a hash or keyword schema, not both"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
raise ArgumentError, "result_schema must be a hash" unless schema.is_a?(Hash)
|
|
180
|
+
|
|
181
|
+
normalized_schema = Class.new(Tool).tap { |tool| tool.input_schema(schema) }.input_schema
|
|
182
|
+
validate_result_schema_keywords!(normalized_schema)
|
|
183
|
+
unless normalized_schema["type"] == "object"
|
|
184
|
+
raise ConfigurationError, "result_schema must describe a top-level object"
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
schema_name = (name || "#{agent_id}_result").to_s
|
|
188
|
+
unless schema_name.match?(/\A[a-zA-Z0-9_-]{1,64}\z/)
|
|
189
|
+
raise ConfigurationError, "result_schema name must contain 1-64 letters, numbers, underscores, or hyphens"
|
|
190
|
+
end
|
|
191
|
+
strategy = strategy.to_sym
|
|
192
|
+
unless StructuredOutput::STRATEGIES.include?(strategy)
|
|
193
|
+
raise ConfigurationError, "result_schema strategy must be auto, provider, or tool"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
self.result_schema_value = {
|
|
197
|
+
schema: normalized_schema,
|
|
198
|
+
name: schema_name,
|
|
199
|
+
description: description&.to_s,
|
|
200
|
+
strategy:
|
|
201
|
+
}
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# :call-seq:
|
|
205
|
+
# capture_diagnostics() -> true or false
|
|
206
|
+
# capture_diagnostics(value) -> true or false
|
|
207
|
+
#
|
|
208
|
+
# Whether agent-layer diagnostics may include model and tool content.
|
|
209
|
+
#
|
|
210
|
+
# Capture defaults to +true+, and only a literal +true+ enables it. This
|
|
211
|
+
# setting does not disable run-level input and output capture from an
|
|
212
|
+
# enabled process-wide Support::ContentCapture policy. For sensitive work,
|
|
213
|
+
# also install Support::ContentCapture.disabled or an appropriate scrubber
|
|
214
|
+
# through Instrumentation.capture_content.
|
|
215
|
+
def capture_diagnostics(*values)
|
|
216
|
+
return capture_diagnostics_value if values.empty?
|
|
217
|
+
|
|
218
|
+
self.capture_diagnostics_value = values.fetch(0) == true
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# :call-seq:
|
|
222
|
+
# system_template() -> String, nil
|
|
223
|
+
# system_template(path) -> String
|
|
224
|
+
#
|
|
225
|
+
# The explicit system prompt template path, when conventional lookup is not used.
|
|
226
|
+
def system_template(*values)
|
|
227
|
+
return system_template_value if values.empty?
|
|
228
|
+
|
|
229
|
+
self.system_template_value = values.fetch(0).to_s
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# :call-seq:
|
|
233
|
+
# system_prompt() -> String, Proc, nil
|
|
234
|
+
# system_prompt(value) -> String
|
|
235
|
+
# system_prompt { |locals| ... } -> Proc
|
|
236
|
+
#
|
|
237
|
+
# The inline system prompt or prompt-building block.
|
|
238
|
+
#
|
|
239
|
+
# Setting an inline prompt clears +system_template+ so one source remains
|
|
240
|
+
# authoritative.
|
|
241
|
+
def system_prompt(*values, &block)
|
|
242
|
+
return system_prompt_builder_value || system_prompt_value if values.empty? && !block
|
|
243
|
+
|
|
244
|
+
self.system_template_value = nil
|
|
245
|
+
if block
|
|
246
|
+
self.system_prompt_value = nil
|
|
247
|
+
self.system_prompt_builder_value = block
|
|
248
|
+
else
|
|
249
|
+
self.system_prompt_value = values.fetch(0).to_s
|
|
250
|
+
self.system_prompt_builder_value = nil
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Adds tool or provider classes to the agent.
|
|
255
|
+
#
|
|
256
|
+
# Every declaration must be a class. A provider class can supply tools
|
|
257
|
+
# dynamically by implementing <tt>tools(binding)</tt>.
|
|
258
|
+
def tools(*values)
|
|
259
|
+
invalid = values.flatten.compact.find { |value| !value.is_a?(Class) }
|
|
260
|
+
if invalid
|
|
261
|
+
raise ConfigurationError, "Class-level tools must be classes"
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
declarations = tool_declarations_value + values
|
|
265
|
+
self.tool_declarations_value = declarations
|
|
266
|
+
tool_declarations
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def tool_declarations = tool_declarations_value # :nodoc:
|
|
270
|
+
|
|
271
|
+
# Adds a named value or resolver to every prompt rendered for the agent.
|
|
272
|
+
def prompt_local(name, *values, &resolver)
|
|
273
|
+
raise ArgumentError, "Provide a prompt local value or block" if values.empty? && !resolver
|
|
274
|
+
raise ArgumentError, "Provide a prompt local value or block, not both" unless values.empty? || !resolver
|
|
275
|
+
|
|
276
|
+
self.prompt_local_values = prompt_local_values.merge(name.to_sym => resolver || values.fetch(0))
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def prompt_local_resolvers = prompt_local_values # :nodoc:
|
|
280
|
+
|
|
281
|
+
def callbacks = callback_values # :nodoc:
|
|
282
|
+
|
|
283
|
+
CALLBACKS.each do |name|
|
|
284
|
+
define_method(name) do |callable = nil, prepend: false, &block|
|
|
285
|
+
callbacks = callback_values.dup
|
|
286
|
+
callbacks.on(name, callable, prepend:, &block)
|
|
287
|
+
self.callback_values = callbacks
|
|
288
|
+
self
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
##
|
|
293
|
+
# Prepares per-agent state after a run-scoped instance is initialized.
|
|
294
|
+
#
|
|
295
|
+
# :singleton-method: after_initialize
|
|
296
|
+
# :call-seq:
|
|
297
|
+
# after_initialize(callable = nil, prepend: false) { |agent| ... } -> self
|
|
298
|
+
|
|
299
|
+
##
|
|
300
|
+
# Runs before one invocation begins.
|
|
301
|
+
#
|
|
302
|
+
# The payload may be continued, replaced, or cancelled with a decision
|
|
303
|
+
# from Support::Callbacks.
|
|
304
|
+
#
|
|
305
|
+
# :singleton-method: before_invocation
|
|
306
|
+
# :call-seq:
|
|
307
|
+
# before_invocation(callable = nil, prepend: false) { |payload| ... } -> self
|
|
308
|
+
|
|
309
|
+
##
|
|
310
|
+
# Observes or transforms the terminal invocation payload.
|
|
311
|
+
#
|
|
312
|
+
# :singleton-method: after_invocation
|
|
313
|
+
# :call-seq:
|
|
314
|
+
# after_invocation(callable = nil, prepend: false) { |payload| ... } -> self
|
|
315
|
+
|
|
316
|
+
##
|
|
317
|
+
# Runs before a model request is sent.
|
|
318
|
+
#
|
|
319
|
+
# :singleton-method: before_model
|
|
320
|
+
# :call-seq:
|
|
321
|
+
# before_model(callable = nil, prepend: false) { |payload| ... } -> self
|
|
322
|
+
|
|
323
|
+
##
|
|
324
|
+
# Observes or transforms a successful model response.
|
|
325
|
+
#
|
|
326
|
+
# :singleton-method: after_model
|
|
327
|
+
# :call-seq:
|
|
328
|
+
# after_model(callable = nil, prepend: false) { |payload| ... } -> self
|
|
329
|
+
|
|
330
|
+
##
|
|
331
|
+
# Handles a model error before it leaves the agent loop.
|
|
332
|
+
#
|
|
333
|
+
# :singleton-method: after_model_error
|
|
334
|
+
# :call-seq:
|
|
335
|
+
# after_model_error(callable = nil, prepend: false) { |payload| ... } -> self
|
|
336
|
+
|
|
337
|
+
##
|
|
338
|
+
# Runs after validation but before a tool call starts.
|
|
339
|
+
#
|
|
340
|
+
# :singleton-method: before_tool
|
|
341
|
+
# :call-seq:
|
|
342
|
+
# before_tool(callable = nil, prepend: false) { |payload| ... } -> self
|
|
343
|
+
|
|
344
|
+
##
|
|
345
|
+
# Observes or transforms a completed tool result.
|
|
346
|
+
#
|
|
347
|
+
# :singleton-method: after_tool
|
|
348
|
+
# :call-seq:
|
|
349
|
+
# after_tool(callable = nil, prepend: false) { |payload| ... } -> self
|
|
350
|
+
|
|
351
|
+
private
|
|
352
|
+
|
|
353
|
+
def validate_result_schema_keywords!(schema, path = "$")
|
|
354
|
+
unsupported = schema.keys - RESULT_SCHEMA_KEYWORDS
|
|
355
|
+
unless unsupported.empty?
|
|
356
|
+
raise ConfigurationError,
|
|
357
|
+
"result_schema contains unsupported keywords at #{path}: #{unsupported.sort.join(", ")}"
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
validate_result_schema_values!(schema, path)
|
|
361
|
+
schema.fetch("properties", {}).each do |name, child|
|
|
362
|
+
validate_result_schema_keywords!(child, "#{path}.properties.#{name}")
|
|
363
|
+
end
|
|
364
|
+
items = schema["items"]
|
|
365
|
+
validate_result_schema_keywords!(items, "#{path}.items") if items
|
|
366
|
+
additional = schema["additionalProperties"]
|
|
367
|
+
if additional.is_a?(Hash)
|
|
368
|
+
validate_result_schema_keywords!(additional, "#{path}.additionalProperties")
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def validate_result_schema_values!(schema, path)
|
|
373
|
+
type = schema["type"]
|
|
374
|
+
types = Array(type)
|
|
375
|
+
supported_types = %w[object array string integer number boolean null]
|
|
376
|
+
if type && (types.empty? || !types.all? { |value| supported_types.include?(value) })
|
|
377
|
+
raise ConfigurationError, "result_schema has an invalid type at #{path}"
|
|
378
|
+
end
|
|
379
|
+
if schema.key?("properties") &&
|
|
380
|
+
(!schema["properties"].is_a?(Hash) || !schema["properties"].values.all? { |value| value.is_a?(Hash) })
|
|
381
|
+
raise ConfigurationError, "result_schema properties must contain object schemas at #{path}"
|
|
382
|
+
end
|
|
383
|
+
if schema.key?("required") &&
|
|
384
|
+
(!schema["required"].is_a?(Array) || !schema["required"].all? { |value| value.is_a?(String) })
|
|
385
|
+
raise ConfigurationError, "result_schema required must be an array of strings at #{path}"
|
|
386
|
+
end
|
|
387
|
+
if type == "object" || Array(type).include?("object") || schema.key?("properties")
|
|
388
|
+
properties = schema.fetch("properties", {})
|
|
389
|
+
unless schema["additionalProperties"] == false
|
|
390
|
+
raise ConfigurationError, "result_schema object must set additionalProperties to false at #{path}"
|
|
391
|
+
end
|
|
392
|
+
unless schema["required"]&.sort == properties.keys.sort
|
|
393
|
+
raise ConfigurationError, "result_schema object must require every property at #{path}"
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
if schema.key?("items") && !schema["items"].is_a?(Hash)
|
|
397
|
+
raise ConfigurationError, "result_schema items must be an object schema at #{path}"
|
|
398
|
+
end
|
|
399
|
+
additional = schema["additionalProperties"]
|
|
400
|
+
if schema.key?("additionalProperties") && additional != true && additional != false && !additional.is_a?(Hash)
|
|
401
|
+
raise ConfigurationError, "result_schema additionalProperties must be boolean or an object schema at #{path}"
|
|
402
|
+
end
|
|
403
|
+
if schema.key?("enum") &&
|
|
404
|
+
(!schema["enum"].is_a?(Array) || !schema["enum"].all? { |value| json_schema_value?(value) })
|
|
405
|
+
raise ConfigurationError, "result_schema enum must contain only JSON values at #{path}"
|
|
406
|
+
end
|
|
407
|
+
%w[minimum maximum].each do |keyword|
|
|
408
|
+
if schema.key?(keyword) &&
|
|
409
|
+
(!schema[keyword].is_a?(Numeric) ||
|
|
410
|
+
(schema[keyword].respond_to?(:finite?) && !schema[keyword].finite?))
|
|
411
|
+
raise ConfigurationError, "result_schema #{keyword} must be finite and numeric at #{path}"
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
%w[minLength maxLength minItems maxItems].each do |keyword|
|
|
415
|
+
if schema.key?(keyword) && (!schema[keyword].is_a?(Integer) || schema[keyword].negative?)
|
|
416
|
+
raise ConfigurationError, "result_schema #{keyword} must be a non-negative integer at #{path}"
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
%w[$schema title description].each do |keyword|
|
|
420
|
+
if schema.key?(keyword) && !schema[keyword].is_a?(String)
|
|
421
|
+
raise ConfigurationError, "result_schema #{keyword} must be a string at #{path}"
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def json_schema_value?(value)
|
|
427
|
+
case value
|
|
428
|
+
when String, Integer, true, false, nil
|
|
429
|
+
true
|
|
430
|
+
when Numeric
|
|
431
|
+
!value.respond_to?(:finite?) || value.finite?
|
|
432
|
+
when Array
|
|
433
|
+
value.all? { |child| json_schema_value?(child) }
|
|
434
|
+
when Hash
|
|
435
|
+
value.all? { |key, child| key.is_a?(String) && json_schema_value?(child) }
|
|
436
|
+
else
|
|
437
|
+
false
|
|
438
|
+
end
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
def default_agent_id
|
|
442
|
+
value = name.to_s.split("::").last.to_s.gsub(/Agent\z/, "").gsub(/([a-z\d])([A-Z])/, "\\1_\\2").downcase
|
|
443
|
+
(value.empty? ? "agent" : value).freeze
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def underscore(value)
|
|
447
|
+
value.gsub(/([a-z\d])([A-Z])/, "\\1_\\2").downcase
|
|
448
|
+
end
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
# Run-scoped model, tools, lifecycle, delegation, and execution resources
|
|
452
|
+
# available to agent extensions.
|
|
453
|
+
attr_reader :model, :tool_registry, :run, :delegation_activity, :agent_path, :workspace, :sandbox,
|
|
454
|
+
:max_tool_calls
|
|
455
|
+
|
|
456
|
+
# Creates either a standalone entrypoint or a run-scoped agent.
|
|
457
|
+
#
|
|
458
|
+
# Calling <tt>new</tt> without +model+ and +run+ creates the console-friendly
|
|
459
|
+
# standalone form. Runtime builders supply the remaining dependencies and
|
|
460
|
+
# apply class-level limits and declarations.
|
|
461
|
+
def initialize(
|
|
462
|
+
model: nil,
|
|
463
|
+
runtime: nil,
|
|
464
|
+
tools: [],
|
|
465
|
+
template_resolver: nil,
|
|
466
|
+
template_paths: [],
|
|
467
|
+
run: nil,
|
|
468
|
+
executor: Support::Executor.new,
|
|
469
|
+
delegation_activity: nil,
|
|
470
|
+
agent_path: Subagents::AgentPath::ROOT,
|
|
471
|
+
max_turns: 100,
|
|
472
|
+
max_tool_calls: 1_000,
|
|
473
|
+
max_tool_result_tokens: DEFAULT_MAX_TOOL_RESULT_TOKENS,
|
|
474
|
+
model_settings: {},
|
|
475
|
+
workspace: nil,
|
|
476
|
+
sandbox: nil
|
|
477
|
+
)
|
|
478
|
+
if model.nil? && run.nil?
|
|
479
|
+
@standalone = true
|
|
480
|
+
@runtime = runtime || Runtime.new(configuration: LittleGhost.configuration)
|
|
481
|
+
@workspace = workspace
|
|
482
|
+
@sandbox = sandbox
|
|
483
|
+
@owns_resources = true
|
|
484
|
+
@closed = false
|
|
485
|
+
@close_mutex = Mutex.new
|
|
486
|
+
@interruptions_mutex = Mutex.new
|
|
487
|
+
@active_interruptions = []
|
|
488
|
+
return
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
@model = model
|
|
492
|
+
@runtime = runtime || run&.runtime
|
|
493
|
+
@run = run
|
|
494
|
+
@workspace = workspace || run&.workspace
|
|
495
|
+
@sandbox = sandbox || run&.sandbox
|
|
496
|
+
if @runtime.is_a?(Runtime) && !@workspace
|
|
497
|
+
@workspace = @runtime.build_workspace
|
|
498
|
+
@sandbox ||= @runtime.build_sandbox(workspace: @workspace)
|
|
499
|
+
end
|
|
500
|
+
@owns_resources = run.nil? && (@workspace || @sandbox)
|
|
501
|
+
binding = Tool::Binding.new(agent: self, run:, runtime: @runtime, model:, workspace: @workspace, sandbox: @sandbox)
|
|
502
|
+
@tool_registry = ToolRegistry.new(tools, binding:)
|
|
503
|
+
self.class.tool_declarations.each do |declaration|
|
|
504
|
+
@tool_registry.register(declaration, replace: true)
|
|
505
|
+
end
|
|
506
|
+
@structured_output_strategy = StructuredOutput.resolve(
|
|
507
|
+
self.class.result_schema,
|
|
508
|
+
model:,
|
|
509
|
+
ordinary_tools: @tool_registry.specifications
|
|
510
|
+
)
|
|
511
|
+
@model_settings = model_settings.to_h.freeze
|
|
512
|
+
@template_resolver = template_resolver || default_template_resolver(template_paths)
|
|
513
|
+
@executor = executor
|
|
514
|
+
@delegation_activity = delegation_activity
|
|
515
|
+
@agent_path = Subagents::AgentPath.validate!(agent_path)
|
|
516
|
+
@max_turns = Integer(max_turns)
|
|
517
|
+
@max_tool_calls = Integer(max_tool_calls)
|
|
518
|
+
@max_tool_result_tokens = Integer(max_tool_result_tokens)
|
|
519
|
+
@closed = false
|
|
520
|
+
@close_mutex = Mutex.new
|
|
521
|
+
@exclusive_tools_mutex = Mutex.new
|
|
522
|
+
@interruptions_mutex = Mutex.new
|
|
523
|
+
@active_interruptions = []
|
|
524
|
+
raise ArgumentError, "max_turns must be at least 1" if @max_turns < 1
|
|
525
|
+
raise ArgumentError, "max_tool_calls must be at least 1" if @max_tool_calls < 1
|
|
526
|
+
raise ArgumentError, "max_tool_result_tokens must be at least 1" if @max_tool_result_tokens < 1
|
|
527
|
+
apply_cancellation_decision!(run_callbacks(:after_initialize, self))
|
|
528
|
+
rescue
|
|
529
|
+
@tool_registry&.close
|
|
530
|
+
raise
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
# Runtime used to build this agent's model, tools, workspace, and sandbox.
|
|
534
|
+
attr_reader :runtime
|
|
535
|
+
|
|
536
|
+
def entrypoint_name = self.class.agent_id # :nodoc:
|
|
537
|
+
|
|
538
|
+
def dispatch_tools(tool_uses, context:, events:, parent_operation_id:, parent_trace_context: nil) # :nodoc:
|
|
539
|
+
execute_tools(tool_uses, context, events, parent_operation_id:, parent_trace_context:)
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
def build_run(payload) # :nodoc:
|
|
543
|
+
options = {
|
|
544
|
+
agent_class: self.class,
|
|
545
|
+
entrypoint_class: self.class
|
|
546
|
+
}
|
|
547
|
+
options[:workspace] = workspace if workspace
|
|
548
|
+
options[:sandbox] = sandbox if sandbox
|
|
549
|
+
runtime.build_run(payload, **options)
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
# Runs +input+ to completion.
|
|
553
|
+
#
|
|
554
|
+
# A standalone agent returns a LittleGhost::Run. An agent built inside a run
|
|
555
|
+
# returns its LittleGhost::RunResult.
|
|
556
|
+
def call(input = nil, **options)
|
|
557
|
+
return build_run(entrypoint_payload(input, options)).call if @standalone
|
|
558
|
+
|
|
559
|
+
result = nil
|
|
560
|
+
stream(input, **options).each do |event|
|
|
561
|
+
result = event.data[:result] if event.type == :invocation_stop
|
|
562
|
+
end
|
|
563
|
+
result
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
# Console-friendly name for +#call+.
|
|
567
|
+
def ask(message, **options)
|
|
568
|
+
call(message, **options)
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
# Adds +message+ to one active invocation and waits for its ordinary text reply.
|
|
572
|
+
#
|
|
573
|
+
# The response may continue into tool calls; delivery does not stop the
|
|
574
|
+
# original invocation. Raises LittleGhost::AgentInterruptError when there is
|
|
575
|
+
# no unambiguous active target.
|
|
576
|
+
def interrupt(
|
|
577
|
+
message,
|
|
578
|
+
cancellation_token: Support::CancellationToken.new,
|
|
579
|
+
deadline: nil,
|
|
580
|
+
target_operation_id: nil,
|
|
581
|
+
interruption_id: nil,
|
|
582
|
+
batch_key: nil,
|
|
583
|
+
metadata: {}
|
|
584
|
+
)
|
|
585
|
+
interrupt_response(
|
|
586
|
+
message,
|
|
587
|
+
cancellation_token:,
|
|
588
|
+
deadline:,
|
|
589
|
+
target_operation_id:,
|
|
590
|
+
interruption_id:,
|
|
591
|
+
batch_key:,
|
|
592
|
+
metadata:
|
|
593
|
+
).text
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
# Adds an interruption and returns the model's immediate response details.
|
|
597
|
+
#
|
|
598
|
+
# Use +target_operation_id+ when an agent has multiple active invocations.
|
|
599
|
+
# Messages may contain only text, image, or document content. The returned
|
|
600
|
+
# response value exposes +text+, +tool_calls?+, +interruption_ids+, and
|
|
601
|
+
# +batch_key+; tool calls may continue after this response. Depend on these
|
|
602
|
+
# methods rather than the response's concrete class.
|
|
603
|
+
def interrupt_response(
|
|
604
|
+
message,
|
|
605
|
+
cancellation_token: Support::CancellationToken.new,
|
|
606
|
+
deadline: nil,
|
|
607
|
+
target_operation_id: nil,
|
|
608
|
+
interruption_id: nil,
|
|
609
|
+
batch_key: nil,
|
|
610
|
+
metadata: {}
|
|
611
|
+
)
|
|
612
|
+
message = Message.new(role: :user, content: message) if message.is_a?(String)
|
|
613
|
+
raise ArgumentError, "interrupt message must be a String or LittleGhost::Message" unless message.is_a?(Message)
|
|
614
|
+
safe_content = message.content.all? do |content|
|
|
615
|
+
content.is_a?(Content::Text) ||
|
|
616
|
+
content.is_a?(Content::Image) ||
|
|
617
|
+
content.is_a?(Content::Document)
|
|
618
|
+
end
|
|
619
|
+
unless safe_content
|
|
620
|
+
raise ArgumentError, "interrupt message content must contain only text, images, or documents"
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
interruptions = @interruptions_mutex.synchronize do
|
|
624
|
+
active = if target_operation_id
|
|
625
|
+
@active_interruptions.select { |candidate| candidate.target_operation_id == target_operation_id }
|
|
626
|
+
else
|
|
627
|
+
@active_interruptions
|
|
628
|
+
end
|
|
629
|
+
if active.empty?
|
|
630
|
+
raise AgentInterruptError, "Agent is not currently running"
|
|
631
|
+
end
|
|
632
|
+
if active.length > 1
|
|
633
|
+
raise AgentInterruptError, "Agent has multiple active invocations; the interruption target is ambiguous"
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
active.first
|
|
637
|
+
end
|
|
638
|
+
options = {batch_key:, metadata:}
|
|
639
|
+
options[:id] = interruption_id unless interruption_id.nil?
|
|
640
|
+
ticket = interruptions.enqueue(message, **options)
|
|
641
|
+
instrument(
|
|
642
|
+
:agent_interrupt_queued,
|
|
643
|
+
parent_operation_id: interruptions.operation_id,
|
|
644
|
+
interruption_id: ticket.id,
|
|
645
|
+
event_kind: :interrupt,
|
|
646
|
+
diagnostic: {input: diagnostic_message(message)}
|
|
647
|
+
)
|
|
648
|
+
begin
|
|
649
|
+
response = ticket.value(cancellation_token:, deadline:)
|
|
650
|
+
interruptions.release(ticket)
|
|
651
|
+
response
|
|
652
|
+
rescue => error
|
|
653
|
+
interruptions.release(ticket, withdraw: true)
|
|
654
|
+
instrument(
|
|
655
|
+
:agent_interrupt_failed,
|
|
656
|
+
parent_operation_id: interruptions.operation_id,
|
|
657
|
+
interruption_id: ticket.id,
|
|
658
|
+
event_kind: :interrupt,
|
|
659
|
+
error_type: error.class.name,
|
|
660
|
+
diagnostic: {exception: diagnostic_exception(error)}
|
|
661
|
+
)
|
|
662
|
+
raise
|
|
663
|
+
end
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
# Streams one invocation as StreamEvent objects.
|
|
667
|
+
#
|
|
668
|
+
# Agents built inside a run accept history, JSON-like context, cancellation,
|
|
669
|
+
# deadlines, settings, and trusted invocation template paths. An agent
|
|
670
|
+
# instance may be streamed only according to the lifecycle managed by its
|
|
671
|
+
# owning run. Every template path must be an application-created TrustedPath;
|
|
672
|
+
# the wrapper records a trust decision and must never contain unchecked
|
|
673
|
+
# request or model input.
|
|
674
|
+
def stream(
|
|
675
|
+
input = nil,
|
|
676
|
+
history: nil,
|
|
677
|
+
context: nil,
|
|
678
|
+
cancellation_token: Support::CancellationToken.new,
|
|
679
|
+
deadline: nil,
|
|
680
|
+
settings: nil,
|
|
681
|
+
template_locals: nil,
|
|
682
|
+
template_paths: nil,
|
|
683
|
+
parent_operation_id: nil,
|
|
684
|
+
checkpoint: nil,
|
|
685
|
+
conversation_id: nil,
|
|
686
|
+
interruption_metadata: nil,
|
|
687
|
+
interruption_ids: [],
|
|
688
|
+
interrupt_ready: nil
|
|
689
|
+
)
|
|
690
|
+
if @standalone
|
|
691
|
+
raise ArgumentError, "input is required" if input.nil?
|
|
692
|
+
|
|
693
|
+
return build_run(entrypoint_payload(input, {})).each
|
|
694
|
+
end
|
|
695
|
+
|
|
696
|
+
raise ArgumentError, "input is required" if input.nil?
|
|
697
|
+
|
|
698
|
+
history ||= []
|
|
699
|
+
context ||= {}
|
|
700
|
+
settings ||= {}
|
|
701
|
+
template_locals ||= {}
|
|
702
|
+
template_paths ||= []
|
|
703
|
+
invocation_paths = Array(template_paths).map do |path|
|
|
704
|
+
unless path.is_a?(LittleGhost::TrustedPath)
|
|
705
|
+
raise ArgumentError, "invocation template paths must be LittleGhost::TrustedPath values"
|
|
706
|
+
end
|
|
707
|
+
path
|
|
708
|
+
end
|
|
709
|
+
settings = @model_settings.merge(settings)
|
|
710
|
+
Enumerator.new do |events|
|
|
711
|
+
interruptions = AgentInterruptions.new
|
|
712
|
+
run_context = RunContext.new(
|
|
713
|
+
state: context,
|
|
714
|
+
cancellation_token: cancellation_token,
|
|
715
|
+
deadline: deadline,
|
|
716
|
+
metadata: {agent_id: self.class.agent_id},
|
|
717
|
+
checkpoint:,
|
|
718
|
+
conversation_id:,
|
|
719
|
+
interruption_metadata:,
|
|
720
|
+
interruption_ids:
|
|
721
|
+
)
|
|
722
|
+
begin
|
|
723
|
+
with_invocation(run_context) do
|
|
724
|
+
execute(
|
|
725
|
+
input,
|
|
726
|
+
history: history,
|
|
727
|
+
context: run_context,
|
|
728
|
+
settings: settings,
|
|
729
|
+
template_locals: template_locals,
|
|
730
|
+
template_paths: invocation_paths,
|
|
731
|
+
events: events,
|
|
732
|
+
parent_operation_id:,
|
|
733
|
+
interruptions:,
|
|
734
|
+
interrupt_ready:
|
|
735
|
+
)
|
|
736
|
+
end
|
|
737
|
+
rescue => error
|
|
738
|
+
interruptions.close(error)
|
|
739
|
+
raise
|
|
740
|
+
ensure
|
|
741
|
+
interruptions.close(AgentInterruptError.new("Agent finished before the interruption was delivered"))
|
|
742
|
+
unregister_interruptions(interruptions)
|
|
743
|
+
end
|
|
744
|
+
end
|
|
745
|
+
end
|
|
746
|
+
|
|
747
|
+
# Console-friendly name for +#stream+.
|
|
748
|
+
def stream_ask(message, **options)
|
|
749
|
+
stream(message, **options)
|
|
750
|
+
end
|
|
751
|
+
|
|
752
|
+
# Exposes this agent as a Tool instance.
|
|
753
|
+
#
|
|
754
|
+
# By default, each call starts with empty conversational history. Set
|
|
755
|
+
# <tt>preserve_context: true</tt> to retain history serially between calls.
|
|
756
|
+
def as_tool(name: self.class.agent_id, description: self.class.description, preserve_context: false)
|
|
757
|
+
agent = self
|
|
758
|
+
description = "Delegate a task to #{name}." if description.to_s.empty?
|
|
759
|
+
mutex = Mutex.new
|
|
760
|
+
retained_history = []
|
|
761
|
+
tool_class = Tool.define(
|
|
762
|
+
name: name,
|
|
763
|
+
description: description,
|
|
764
|
+
input_schema: {
|
|
765
|
+
type: "object",
|
|
766
|
+
properties: {input: {type: "string"}},
|
|
767
|
+
required: ["input"],
|
|
768
|
+
additionalProperties: false
|
|
769
|
+
}
|
|
770
|
+
) do |input, context: nil|
|
|
771
|
+
invocation = lambda do
|
|
772
|
+
result = agent.call(
|
|
773
|
+
input.fetch("input"),
|
|
774
|
+
history: preserve_context ? retained_history : [],
|
|
775
|
+
context: context&.state || {},
|
|
776
|
+
cancellation_token: context&.cancellation_token || Support::CancellationToken.new,
|
|
777
|
+
interruption_metadata: context&.interruption_metadata,
|
|
778
|
+
interruption_ids: context&.interruption_ids || [],
|
|
779
|
+
deadline: context&.deadline,
|
|
780
|
+
parent_operation_id: run&.operation_id
|
|
781
|
+
)
|
|
782
|
+
retained_history.replace(result.messages.reject { |message| message.role == :system }) if preserve_context
|
|
783
|
+
result.structured? ? result.structured_result.value : result.text
|
|
784
|
+
end
|
|
785
|
+
preserve_context ? mutex.synchronize(&invocation) : invocation.call
|
|
786
|
+
end
|
|
787
|
+
tool_class.define_method(:close) { agent.close }
|
|
788
|
+
binding = Tool::Binding.new(
|
|
789
|
+
agent: self,
|
|
790
|
+
run:,
|
|
791
|
+
runtime:,
|
|
792
|
+
model:,
|
|
793
|
+
workspace:,
|
|
794
|
+
sandbox:
|
|
795
|
+
)
|
|
796
|
+
tool_class.new(binding:)
|
|
797
|
+
end
|
|
798
|
+
|
|
799
|
+
# Materializes and freezes the prompt locals declared on the agent class.
|
|
800
|
+
def prompt_locals
|
|
801
|
+
self.class.prompt_local_resolvers.to_h do |name, resolver|
|
|
802
|
+
value = if resolver.respond_to?(:call)
|
|
803
|
+
resolver.parameters.empty? ? instance_exec(&resolver) : resolver.call(self)
|
|
804
|
+
else
|
|
805
|
+
resolver
|
|
806
|
+
end
|
|
807
|
+
[name, value]
|
|
808
|
+
end.freeze
|
|
809
|
+
end
|
|
810
|
+
|
|
811
|
+
# The materialized tools available during this agent run.
|
|
812
|
+
def tools = tool_registry
|
|
813
|
+
|
|
814
|
+
# Closes owned tools, interruptions, sandbox, and workspace resources.
|
|
815
|
+
# The operation is idempotent and re-raises the first cleanup failure.
|
|
816
|
+
def close
|
|
817
|
+
resources, interruptions = @close_mutex.synchronize do
|
|
818
|
+
return if @closed
|
|
819
|
+
|
|
820
|
+
@closed = true
|
|
821
|
+
[
|
|
822
|
+
[tool_registry, (@sandbox if @owns_resources), (@workspace if @owns_resources)],
|
|
823
|
+
@interruptions_mutex.synchronize { @active_interruptions.dup }
|
|
824
|
+
]
|
|
825
|
+
end
|
|
826
|
+
first_error = nil
|
|
827
|
+
interruptions.each do |active|
|
|
828
|
+
active.close(AgentInterruptError.new("Agent was closed"))
|
|
829
|
+
end
|
|
830
|
+
resources.each do |resource|
|
|
831
|
+
resource.close if resource.respond_to?(:close)
|
|
832
|
+
rescue => error
|
|
833
|
+
first_error ||= error
|
|
834
|
+
end
|
|
835
|
+
raise first_error if first_error
|
|
836
|
+
end
|
|
837
|
+
|
|
838
|
+
protected
|
|
839
|
+
|
|
840
|
+
# :doc:
|
|
841
|
+
# Yields around one invocation. Subclasses may override this hook to install
|
|
842
|
+
# invocation-scoped state and must yield exactly once.
|
|
843
|
+
def with_invocation(_context)
|
|
844
|
+
yield
|
|
845
|
+
end
|
|
846
|
+
|
|
847
|
+
# :doc:
|
|
848
|
+
# Returns the tools exposed to the model for +turn+. Subclasses may override
|
|
849
|
+
# this hook to filter the already-authorized tool list.
|
|
850
|
+
def model_tools(tools, context:, turn:)
|
|
851
|
+
tools
|
|
852
|
+
end
|
|
853
|
+
|
|
854
|
+
# :doc:
|
|
855
|
+
# Yields around one tool execution. Subclasses may override this hook for
|
|
856
|
+
# execution-scoped behavior and must yield exactly once.
|
|
857
|
+
def with_tool_execution(_execution)
|
|
858
|
+
yield
|
|
859
|
+
end
|
|
860
|
+
|
|
861
|
+
private
|
|
862
|
+
|
|
863
|
+
def entrypoint_payload(input, options)
|
|
864
|
+
return options if input.nil?
|
|
865
|
+
return input.merge(options) if input.is_a?(Hash)
|
|
866
|
+
|
|
867
|
+
{message: input, **options}
|
|
868
|
+
end
|
|
869
|
+
|
|
870
|
+
def register_interruptions(interruptions)
|
|
871
|
+
@close_mutex.synchronize do
|
|
872
|
+
raise InvocationError, "Agent is closed" if @closed
|
|
873
|
+
|
|
874
|
+
@interruptions_mutex.synchronize { @active_interruptions << interruptions }
|
|
875
|
+
end
|
|
876
|
+
end
|
|
877
|
+
|
|
878
|
+
def unregister_interruptions(interruptions)
|
|
879
|
+
@interruptions_mutex.synchronize { @active_interruptions.delete(interruptions) }
|
|
880
|
+
end
|
|
881
|
+
|
|
882
|
+
def interruption_message(interruption)
|
|
883
|
+
source = interruption.message
|
|
884
|
+
Message.new(
|
|
885
|
+
role: :user,
|
|
886
|
+
content: [
|
|
887
|
+
Content::Text.new(text: <<~MESSAGE.strip),
|
|
888
|
+
Agent interruption:
|
|
889
|
+
|
|
890
|
+
Respond briefly in ordinary text before any tool calls, then continue the current task unless this
|
|
891
|
+
interruption asks you to finish.
|
|
892
|
+
MESSAGE
|
|
893
|
+
*source.content
|
|
894
|
+
],
|
|
895
|
+
metadata: source.metadata.merge(interruption.metadata).merge(
|
|
896
|
+
little_ghost_interruption_id: interruption.id,
|
|
897
|
+
little_ghost_interruption_batch_key: interruption.batch_key,
|
|
898
|
+
little_ghost_interruption_metadata: interruption.metadata
|
|
899
|
+
)
|
|
900
|
+
)
|
|
901
|
+
end
|
|
902
|
+
|
|
903
|
+
def request_with_interruption(request, interruption)
|
|
904
|
+
ModelRequest.new(
|
|
905
|
+
messages: [
|
|
906
|
+
*request.messages,
|
|
907
|
+
*interruption.tickets.reject { |ticket| request_contains_interruption?(request, ticket) }
|
|
908
|
+
.map { |ticket| interruption_message(ticket) }
|
|
909
|
+
],
|
|
910
|
+
tools: request.tools,
|
|
911
|
+
settings: request.settings,
|
|
912
|
+
output_schema: nil,
|
|
913
|
+
tool_choice: nil,
|
|
914
|
+
required_capabilities: request.tools.empty? ? [] : [:tools],
|
|
915
|
+
cancellation_token: request.cancellation_token,
|
|
916
|
+
deadline: request.deadline
|
|
917
|
+
)
|
|
918
|
+
end
|
|
919
|
+
|
|
920
|
+
def request_contains_interruption?(request, interruption)
|
|
921
|
+
request.messages.any? do |message|
|
|
922
|
+
(message.metadata[:little_ghost_interruption_id] ||
|
|
923
|
+
message.metadata["little_ghost_interruption_id"]) == interruption.id
|
|
924
|
+
end
|
|
925
|
+
end
|
|
926
|
+
|
|
927
|
+
def execute(
|
|
928
|
+
input,
|
|
929
|
+
history:,
|
|
930
|
+
context:,
|
|
931
|
+
settings:,
|
|
932
|
+
template_locals:,
|
|
933
|
+
template_paths:,
|
|
934
|
+
events:,
|
|
935
|
+
parent_operation_id:,
|
|
936
|
+
interruptions:,
|
|
937
|
+
interrupt_ready:
|
|
938
|
+
)
|
|
939
|
+
started_at = monotonic_time
|
|
940
|
+
operation_id = SecureRandom.uuid
|
|
941
|
+
context.bind_agent_operation_id(operation_id)
|
|
942
|
+
interruptions.bind(operation_id, target_operation_id: parent_operation_id)
|
|
943
|
+
register_interruptions(interruptions)
|
|
944
|
+
interrupt_ready&.call
|
|
945
|
+
agent_handle = start_instrumentation(
|
|
946
|
+
:agent,
|
|
947
|
+
parent: parent_operation_id || active_instrumentation_parent,
|
|
948
|
+
operation_id:,
|
|
949
|
+
available_tools: tool_registry.names,
|
|
950
|
+
diagnostic: {input: diagnostic_input(input)}
|
|
951
|
+
)
|
|
952
|
+
context.check!
|
|
953
|
+
messages = history.map { |message| Message.coerce(message) }
|
|
954
|
+
prompt = rendered_system_prompt(template_locals, template_paths)
|
|
955
|
+
messages.unshift(Message.new(role: :system, content: prompt)) unless prompt.to_s.empty?
|
|
956
|
+
messages << (input.is_a?(Message) ? input : Message.new(role: :user, content: input))
|
|
957
|
+
tool_call_count = 0
|
|
958
|
+
structured_result_repair_due = false
|
|
959
|
+
|
|
960
|
+
decision = run_callbacks(:before_invocation, {messages: messages}, context: context)
|
|
961
|
+
apply_cancellation_decision!(decision)
|
|
962
|
+
messages = replacement_value(decision, :messages, messages)
|
|
963
|
+
context.checkpoint(messages)
|
|
964
|
+
emit(events, :invocation_start, agent_id: self.class.agent_id)
|
|
965
|
+
|
|
966
|
+
@max_turns.times do |turn|
|
|
967
|
+
turn_operation_id = SecureRandom.uuid
|
|
968
|
+
turn_handle = start_instrumentation(
|
|
969
|
+
:agent_turn,
|
|
970
|
+
operation_id: turn_operation_id,
|
|
971
|
+
turn: turn + 1
|
|
972
|
+
)
|
|
973
|
+
begin
|
|
974
|
+
context.check!
|
|
975
|
+
response, interrupted = invoke_model(
|
|
976
|
+
messages,
|
|
977
|
+
context,
|
|
978
|
+
settings,
|
|
979
|
+
turn,
|
|
980
|
+
events,
|
|
981
|
+
parent_operation_id: turn_operation_id,
|
|
982
|
+
structured_result_repair_due:,
|
|
983
|
+
interruptions:
|
|
984
|
+
)
|
|
985
|
+
messages << response.message
|
|
986
|
+
tool_uses = response.message.content.grep(Content::ToolUse)
|
|
987
|
+
result_tool_uses = structured_result_tool_uses(tool_uses)
|
|
988
|
+
|
|
989
|
+
unless result_tool_uses.empty?
|
|
990
|
+
validation_error = capture_structured_result_tool(
|
|
991
|
+
tool_uses,
|
|
992
|
+
result_tool_uses,
|
|
993
|
+
context
|
|
994
|
+
)
|
|
995
|
+
unless validation_error
|
|
996
|
+
messages[-1] = redact_structured_result_message(response.message)
|
|
997
|
+
unless interruptions.finish
|
|
998
|
+
context.checkpoint(messages)
|
|
999
|
+
finish_instrumentation(
|
|
1000
|
+
turn_handle,
|
|
1001
|
+
operation_id: turn_operation_id,
|
|
1002
|
+
outcome: :interrupted,
|
|
1003
|
+
turn: turn + 1
|
|
1004
|
+
)
|
|
1005
|
+
next
|
|
1006
|
+
end
|
|
1007
|
+
return complete_structured_result(
|
|
1008
|
+
response,
|
|
1009
|
+
messages,
|
|
1010
|
+
context,
|
|
1011
|
+
events,
|
|
1012
|
+
agent_handle:,
|
|
1013
|
+
turn_handle:,
|
|
1014
|
+
turn: turn + 1,
|
|
1015
|
+
started_at:,
|
|
1016
|
+
repaired: structured_result_repair_due
|
|
1017
|
+
)
|
|
1018
|
+
end
|
|
1019
|
+
|
|
1020
|
+
if structured_result_repair_due
|
|
1021
|
+
raise_structured_result_error!(
|
|
1022
|
+
"The model did not return a valid structured result after its repair turn",
|
|
1023
|
+
context,
|
|
1024
|
+
operation_id:,
|
|
1025
|
+
started_at:,
|
|
1026
|
+
validation_errors: [validation_error]
|
|
1027
|
+
)
|
|
1028
|
+
end
|
|
1029
|
+
|
|
1030
|
+
structured_result_repair_due = true
|
|
1031
|
+
messages[-1] = redact_structured_result_tool_message(response.message)
|
|
1032
|
+
messages << Message.new(
|
|
1033
|
+
role: :tool,
|
|
1034
|
+
content: structured_result_tool_errors(tool_uses)
|
|
1035
|
+
)
|
|
1036
|
+
context.checkpoint(messages)
|
|
1037
|
+
instrument_structured_result_repair(
|
|
1038
|
+
operation_id,
|
|
1039
|
+
started_at,
|
|
1040
|
+
context,
|
|
1041
|
+
validation_status: :invalid
|
|
1042
|
+
)
|
|
1043
|
+
finish_instrumentation(
|
|
1044
|
+
turn_handle,
|
|
1045
|
+
operation_id: turn_operation_id,
|
|
1046
|
+
outcome: :repair,
|
|
1047
|
+
turn: turn + 1
|
|
1048
|
+
)
|
|
1049
|
+
next
|
|
1050
|
+
end
|
|
1051
|
+
|
|
1052
|
+
if tool_uses.empty?
|
|
1053
|
+
if %i[max_tokens limit_output_tokens limit_total_tokens limit_turns].include?(response.stop_reason)
|
|
1054
|
+
raise OutputLimitError, "The model stopped before completing its response"
|
|
1055
|
+
end
|
|
1056
|
+
|
|
1057
|
+
if interrupted || !@structured_output_strategy
|
|
1058
|
+
unless interruptions.finish
|
|
1059
|
+
context.checkpoint(messages)
|
|
1060
|
+
finish_instrumentation(
|
|
1061
|
+
turn_handle,
|
|
1062
|
+
operation_id: turn_operation_id,
|
|
1063
|
+
outcome: :interrupted,
|
|
1064
|
+
turn: turn + 1
|
|
1065
|
+
)
|
|
1066
|
+
next
|
|
1067
|
+
end
|
|
1068
|
+
end
|
|
1069
|
+
|
|
1070
|
+
if @structured_output_strategy && !interrupted
|
|
1071
|
+
validation_error = if @structured_output_strategy.provider?
|
|
1072
|
+
capture_structured_result(response.message.text, context)
|
|
1073
|
+
else
|
|
1074
|
+
"The structured result tool was not called"
|
|
1075
|
+
end
|
|
1076
|
+
unless validation_error
|
|
1077
|
+
messages[-1] = redact_structured_result_message(response.message)
|
|
1078
|
+
unless interruptions.finish
|
|
1079
|
+
context.checkpoint(messages)
|
|
1080
|
+
finish_instrumentation(
|
|
1081
|
+
turn_handle,
|
|
1082
|
+
operation_id: turn_operation_id,
|
|
1083
|
+
outcome: :interrupted,
|
|
1084
|
+
turn: turn + 1
|
|
1085
|
+
)
|
|
1086
|
+
next
|
|
1087
|
+
end
|
|
1088
|
+
return complete_structured_result(
|
|
1089
|
+
response,
|
|
1090
|
+
messages,
|
|
1091
|
+
context,
|
|
1092
|
+
events,
|
|
1093
|
+
agent_handle:,
|
|
1094
|
+
turn_handle:,
|
|
1095
|
+
turn: turn + 1,
|
|
1096
|
+
started_at:,
|
|
1097
|
+
repaired: structured_result_repair_due
|
|
1098
|
+
)
|
|
1099
|
+
end
|
|
1100
|
+
|
|
1101
|
+
if structured_result_repair_due
|
|
1102
|
+
raise_structured_result_error!(
|
|
1103
|
+
"The model did not return a valid structured result after its repair turn",
|
|
1104
|
+
context,
|
|
1105
|
+
operation_id:,
|
|
1106
|
+
started_at:,
|
|
1107
|
+
validation_errors: [validation_error]
|
|
1108
|
+
)
|
|
1109
|
+
end
|
|
1110
|
+
|
|
1111
|
+
structured_result_repair_due = true
|
|
1112
|
+
messages[-1] = redact_structured_result_message(response.message)
|
|
1113
|
+
messages << structured_result_repair_message
|
|
1114
|
+
context.checkpoint(messages)
|
|
1115
|
+
instrument_structured_result_repair(
|
|
1116
|
+
operation_id,
|
|
1117
|
+
started_at,
|
|
1118
|
+
context,
|
|
1119
|
+
validation_status: :missing
|
|
1120
|
+
)
|
|
1121
|
+
finish_instrumentation(
|
|
1122
|
+
turn_handle,
|
|
1123
|
+
operation_id: turn_operation_id,
|
|
1124
|
+
outcome: :repair,
|
|
1125
|
+
turn: turn + 1
|
|
1126
|
+
)
|
|
1127
|
+
next
|
|
1128
|
+
end
|
|
1129
|
+
|
|
1130
|
+
context.checkpoint(messages)
|
|
1131
|
+
|
|
1132
|
+
result = RunResult.new(
|
|
1133
|
+
message: response.message,
|
|
1134
|
+
stop_reason: response.stop_reason,
|
|
1135
|
+
usage: context.usage,
|
|
1136
|
+
messages: messages.freeze,
|
|
1137
|
+
state: context.state
|
|
1138
|
+
)
|
|
1139
|
+
decision = run_callbacks(:after_invocation, {result: result}, context: context)
|
|
1140
|
+
apply_cancellation_decision!(decision)
|
|
1141
|
+
result = replacement_value(decision, :result, result)
|
|
1142
|
+
context.checkpoint(result.messages)
|
|
1143
|
+
finish_instrumentation(
|
|
1144
|
+
turn_handle,
|
|
1145
|
+
operation_id: turn_operation_id,
|
|
1146
|
+
outcome: :completed,
|
|
1147
|
+
turn: turn + 1
|
|
1148
|
+
)
|
|
1149
|
+
metadata = model.respond_to?(:metadata) ? model.metadata : {}
|
|
1150
|
+
finish_instrumentation(
|
|
1151
|
+
agent_handle,
|
|
1152
|
+
outcome: :completed,
|
|
1153
|
+
duration_ms: duration_ms(started_at),
|
|
1154
|
+
stop_reason: result.stop_reason,
|
|
1155
|
+
operation_id:,
|
|
1156
|
+
diagnostic: {output: diagnostic_message(result.message)},
|
|
1157
|
+
**usage_attributes(result.usage)
|
|
1158
|
+
)
|
|
1159
|
+
emit(events, :invocation_stop, result: result, metadata:)
|
|
1160
|
+
return result
|
|
1161
|
+
end
|
|
1162
|
+
|
|
1163
|
+
if @structured_output_strategy && structured_result_repair_due
|
|
1164
|
+
raise_structured_result_error!(
|
|
1165
|
+
"The model did not return a structured result after its repair turn",
|
|
1166
|
+
context,
|
|
1167
|
+
operation_id:,
|
|
1168
|
+
started_at:,
|
|
1169
|
+
validation_errors: ["The structured result was not returned"]
|
|
1170
|
+
)
|
|
1171
|
+
end
|
|
1172
|
+
|
|
1173
|
+
tool_call_count += tool_uses.length
|
|
1174
|
+
raise ProtocolError, "The agent reached its maximum tool calls" if tool_call_count > @max_tool_calls
|
|
1175
|
+
|
|
1176
|
+
tool_results = dispatch_tools(
|
|
1177
|
+
tool_uses,
|
|
1178
|
+
context:,
|
|
1179
|
+
events:,
|
|
1180
|
+
parent_operation_id: turn_operation_id
|
|
1181
|
+
)
|
|
1182
|
+
messages << Message.new(
|
|
1183
|
+
role: :tool,
|
|
1184
|
+
content: tool_results
|
|
1185
|
+
)
|
|
1186
|
+
context.checkpoint(messages)
|
|
1187
|
+
finish_instrumentation(
|
|
1188
|
+
turn_handle,
|
|
1189
|
+
operation_id: turn_operation_id,
|
|
1190
|
+
outcome: :completed,
|
|
1191
|
+
turn: turn + 1
|
|
1192
|
+
)
|
|
1193
|
+
rescue => error
|
|
1194
|
+
finish_instrumentation(
|
|
1195
|
+
turn_handle,
|
|
1196
|
+
operation_id: turn_operation_id,
|
|
1197
|
+
outcome: :error,
|
|
1198
|
+
turn: turn + 1,
|
|
1199
|
+
error_type: error.class.name,
|
|
1200
|
+
diagnostic: {exception: diagnostic_exception(error)}
|
|
1201
|
+
)
|
|
1202
|
+
raise
|
|
1203
|
+
end
|
|
1204
|
+
end
|
|
1205
|
+
|
|
1206
|
+
if @structured_output_strategy
|
|
1207
|
+
raise_structured_result_error!(
|
|
1208
|
+
structured_result_repair_due ?
|
|
1209
|
+
"The agent reached its model turn limit before it could repair the structured result" :
|
|
1210
|
+
"The agent reached its model turn limit before returning a structured result",
|
|
1211
|
+
context,
|
|
1212
|
+
operation_id:,
|
|
1213
|
+
started_at:,
|
|
1214
|
+
validation_errors: [
|
|
1215
|
+
structured_result_repair_due ? "repair turn unavailable" : "structured result was not submitted"
|
|
1216
|
+
],
|
|
1217
|
+
repair_attempted: structured_result_repair_due
|
|
1218
|
+
)
|
|
1219
|
+
end
|
|
1220
|
+
raise ProtocolError, "The agent reached its maximum model turns"
|
|
1221
|
+
rescue => error
|
|
1222
|
+
finish_instrumentation(
|
|
1223
|
+
agent_handle,
|
|
1224
|
+
operation_id:,
|
|
1225
|
+
outcome: :error,
|
|
1226
|
+
duration_ms: duration_ms(started_at),
|
|
1227
|
+
error_type: error.class.name,
|
|
1228
|
+
diagnostic: {exception: diagnostic_exception(error)},
|
|
1229
|
+
**usage_attributes(context.usage)
|
|
1230
|
+
)
|
|
1231
|
+
metadata = model.respond_to?(:metadata) ? model.metadata : {}
|
|
1232
|
+
emit(events, :invocation_error, error:, usage: context.usage, metadata:)
|
|
1233
|
+
raise
|
|
1234
|
+
end
|
|
1235
|
+
|
|
1236
|
+
def invoke_model(
|
|
1237
|
+
messages,
|
|
1238
|
+
context,
|
|
1239
|
+
settings,
|
|
1240
|
+
turn,
|
|
1241
|
+
events,
|
|
1242
|
+
parent_operation_id:,
|
|
1243
|
+
interruptions:,
|
|
1244
|
+
structured_result_repair_due: false,
|
|
1245
|
+
recovery_attempt: 0,
|
|
1246
|
+
interruption: nil
|
|
1247
|
+
)
|
|
1248
|
+
started_at = monotonic_time
|
|
1249
|
+
operation_id = SecureRandom.uuid
|
|
1250
|
+
strategy = @structured_output_strategy
|
|
1251
|
+
ordinary_tools = tool_registry.specifications
|
|
1252
|
+
StructuredOutput.validate_tool_collision!(strategy, ordinary_tools) if strategy
|
|
1253
|
+
request = ModelRequest.new(
|
|
1254
|
+
messages: messages,
|
|
1255
|
+
tools: model_tools(strategy ? strategy.tools(ordinary_tools) : ordinary_tools, context:, turn:),
|
|
1256
|
+
settings: settings,
|
|
1257
|
+
output_schema: strategy&.output_schema,
|
|
1258
|
+
tool_choice: strategy&.tool_choice(repair: structured_result_repair_due),
|
|
1259
|
+
required_capabilities: strategy&.required_capabilities || [],
|
|
1260
|
+
cancellation_token: context.cancellation_token,
|
|
1261
|
+
deadline: context.deadline
|
|
1262
|
+
)
|
|
1263
|
+
interruption ||= interruptions.deliver
|
|
1264
|
+
if interruption
|
|
1265
|
+
context.activate_interruption(metadata: interruption.metadata, ids: interruption.interruption_ids)
|
|
1266
|
+
end
|
|
1267
|
+
decision = run_callbacks(
|
|
1268
|
+
:before_model,
|
|
1269
|
+
{request: request, turn: turn, parent_operation_id:},
|
|
1270
|
+
context: context
|
|
1271
|
+
)
|
|
1272
|
+
apply_cancellation_decision!(decision)
|
|
1273
|
+
request = replacement_value(decision, :request, request)
|
|
1274
|
+
interruption_delivered = interruption&.tickets&.any? do |ticket|
|
|
1275
|
+
!request_contains_interruption?(request, ticket)
|
|
1276
|
+
end
|
|
1277
|
+
if interruption_delivered
|
|
1278
|
+
request = request_with_interruption(request, interruption)
|
|
1279
|
+
end
|
|
1280
|
+
messages.replace(request.messages)
|
|
1281
|
+
context.checkpoint(messages)
|
|
1282
|
+
model_handle = start_instrumentation(
|
|
1283
|
+
:model,
|
|
1284
|
+
operation_id:,
|
|
1285
|
+
turn:,
|
|
1286
|
+
diagnostic: {
|
|
1287
|
+
input: request.messages.map { |message| diagnostic_message(message) },
|
|
1288
|
+
tool_definitions: request.tools
|
|
1289
|
+
},
|
|
1290
|
+
model_settings: request.settings,
|
|
1291
|
+
**model_attributes
|
|
1292
|
+
)
|
|
1293
|
+
if interruption_delivered
|
|
1294
|
+
interruption.tickets.each do |ticket|
|
|
1295
|
+
instrument(
|
|
1296
|
+
:agent_interrupt_delivered,
|
|
1297
|
+
parent_operation_id: operation_id,
|
|
1298
|
+
interruption_id: ticket.id,
|
|
1299
|
+
event_kind: :interrupt
|
|
1300
|
+
)
|
|
1301
|
+
end
|
|
1302
|
+
emit(
|
|
1303
|
+
events,
|
|
1304
|
+
:agent_interrupt_delivered,
|
|
1305
|
+
interruption_ids: interruption.interruption_ids,
|
|
1306
|
+
batch_key: interruption.batch_key
|
|
1307
|
+
)
|
|
1308
|
+
end
|
|
1309
|
+
emit(events, :model_start, turn: turn)
|
|
1310
|
+
response = nil
|
|
1311
|
+
time_to_first_token = nil
|
|
1312
|
+
buffered_events = strategy ? [] : nil
|
|
1313
|
+
|
|
1314
|
+
model.stream(request).each do |event|
|
|
1315
|
+
context.check!
|
|
1316
|
+
time_to_first_token ||= duration_seconds(started_at) if model_output_event?(event)
|
|
1317
|
+
if event.type == :model_retry
|
|
1318
|
+
response = nil
|
|
1319
|
+
instrument(
|
|
1320
|
+
:model_retry,
|
|
1321
|
+
parent_operation_id: operation_id,
|
|
1322
|
+
**event.data.slice(
|
|
1323
|
+
:attempt,
|
|
1324
|
+
:delay,
|
|
1325
|
+
:error_class,
|
|
1326
|
+
:error_code,
|
|
1327
|
+
:http_status,
|
|
1328
|
+
:partial_text
|
|
1329
|
+
),
|
|
1330
|
+
**model_attributes
|
|
1331
|
+
)
|
|
1332
|
+
end
|
|
1333
|
+
buffered_events ? buffered_events << event : events << event
|
|
1334
|
+
response = event.data[:response] if event.type == :message_stop
|
|
1335
|
+
end
|
|
1336
|
+
raise ProtocolError, "The model stream ended without a response" unless response
|
|
1337
|
+
|
|
1338
|
+
context.record_usage(response.usage)
|
|
1339
|
+
provider_response = response
|
|
1340
|
+
|
|
1341
|
+
decision = run_callbacks(:after_model, {request: request, response: response, turn: turn}, context: context)
|
|
1342
|
+
apply_cancellation_decision!(decision)
|
|
1343
|
+
response = replacement_value(decision, :response, response)
|
|
1344
|
+
interruptions.resolve(
|
|
1345
|
+
interruption,
|
|
1346
|
+
AgentInterruptions::Response.new(
|
|
1347
|
+
text: response.message.text,
|
|
1348
|
+
tool_calls: response.message.content.any? { |content| content.is_a?(Content::ToolUse) },
|
|
1349
|
+
interruption_ids: interruption&.interruption_ids || [],
|
|
1350
|
+
batch_key: interruption&.batch_key
|
|
1351
|
+
)
|
|
1352
|
+
)
|
|
1353
|
+
interruption&.tickets&.each do |ticket|
|
|
1354
|
+
instrument(
|
|
1355
|
+
:agent_interrupt_responded,
|
|
1356
|
+
parent_operation_id: operation_id,
|
|
1357
|
+
interruption_id: ticket.id,
|
|
1358
|
+
event_kind: :interrupt,
|
|
1359
|
+
diagnostic: {output: response.message.text}
|
|
1360
|
+
)
|
|
1361
|
+
end
|
|
1362
|
+
if buffered_events
|
|
1363
|
+
publish_model_events(
|
|
1364
|
+
events,
|
|
1365
|
+
buffered_events,
|
|
1366
|
+
provider_response,
|
|
1367
|
+
response,
|
|
1368
|
+
repair: structured_result_repair_due
|
|
1369
|
+
)
|
|
1370
|
+
end
|
|
1371
|
+
redact_response = structured_result_terminal_response?(
|
|
1372
|
+
provider_response,
|
|
1373
|
+
repair: structured_result_repair_due
|
|
1374
|
+
) || structured_result_terminal_response?(
|
|
1375
|
+
response,
|
|
1376
|
+
repair: structured_result_repair_due
|
|
1377
|
+
)
|
|
1378
|
+
diagnostic_response = if redact_response
|
|
1379
|
+
redact_structured_result_payload_message(response.message)
|
|
1380
|
+
else
|
|
1381
|
+
structured_result_diagnostic_message(response.message)
|
|
1382
|
+
end
|
|
1383
|
+
finish_instrumentation(
|
|
1384
|
+
model_handle,
|
|
1385
|
+
operation_id:,
|
|
1386
|
+
parent_operation_id:,
|
|
1387
|
+
turn:,
|
|
1388
|
+
outcome: :completed,
|
|
1389
|
+
duration_ms: duration_ms(started_at),
|
|
1390
|
+
time_to_first_token:,
|
|
1391
|
+
stop_reason: response.stop_reason,
|
|
1392
|
+
**response_attributes(response),
|
|
1393
|
+
diagnostic: {output: diagnostic_message(diagnostic_response)},
|
|
1394
|
+
**model_attributes,
|
|
1395
|
+
**usage_attributes(response.usage)
|
|
1396
|
+
)
|
|
1397
|
+
emit(
|
|
1398
|
+
events,
|
|
1399
|
+
:model_stop,
|
|
1400
|
+
turn: turn,
|
|
1401
|
+
response: structured_result_stream_response(
|
|
1402
|
+
provider_response,
|
|
1403
|
+
response,
|
|
1404
|
+
repair: structured_result_repair_due
|
|
1405
|
+
)
|
|
1406
|
+
)
|
|
1407
|
+
[response, !interruption.nil?]
|
|
1408
|
+
rescue => error
|
|
1409
|
+
finish_instrumentation(
|
|
1410
|
+
model_handle,
|
|
1411
|
+
operation_id:,
|
|
1412
|
+
parent_operation_id:,
|
|
1413
|
+
turn:,
|
|
1414
|
+
outcome: :error,
|
|
1415
|
+
duration_ms: duration_ms(started_at),
|
|
1416
|
+
time_to_first_token:,
|
|
1417
|
+
error_type: error.class.name,
|
|
1418
|
+
**provider_error_attributes(error),
|
|
1419
|
+
diagnostic: {exception: diagnostic_exception(error)},
|
|
1420
|
+
**model_attributes
|
|
1421
|
+
)
|
|
1422
|
+
raise if error.is_a?(CleanupError)
|
|
1423
|
+
|
|
1424
|
+
if recovery_attempt < 3
|
|
1425
|
+
decision = run_callbacks(
|
|
1426
|
+
:after_model_error,
|
|
1427
|
+
{request:, error:, turn:, parent_operation_id:},
|
|
1428
|
+
context:
|
|
1429
|
+
)
|
|
1430
|
+
apply_cancellation_decision!(decision)
|
|
1431
|
+
recovered = replacement_value(decision, :request, nil)
|
|
1432
|
+
if recovered
|
|
1433
|
+
messages.replace(recovered.messages)
|
|
1434
|
+
return invoke_model(
|
|
1435
|
+
messages,
|
|
1436
|
+
context,
|
|
1437
|
+
recovered.settings,
|
|
1438
|
+
turn,
|
|
1439
|
+
events,
|
|
1440
|
+
parent_operation_id:,
|
|
1441
|
+
structured_result_repair_due:,
|
|
1442
|
+
recovery_attempt: recovery_attempt + 1,
|
|
1443
|
+
interruptions:,
|
|
1444
|
+
interruption:
|
|
1445
|
+
)
|
|
1446
|
+
end
|
|
1447
|
+
end
|
|
1448
|
+
raise
|
|
1449
|
+
end
|
|
1450
|
+
|
|
1451
|
+
def execute_tools(tool_uses, context, events, parent_operation_id:, parent_trace_context: nil)
|
|
1452
|
+
if tool_uses.map(&:id).uniq.length != tool_uses.length
|
|
1453
|
+
raise ProtocolError, "The model returned duplicate tool use ids"
|
|
1454
|
+
end
|
|
1455
|
+
|
|
1456
|
+
tool_uses.each { |tool_use| emit(events, :tool_start, tool_use: tool_use) }
|
|
1457
|
+
pairs = tool_uses.map do |tool_use|
|
|
1458
|
+
[tool_use, tool_registry.fetch(tool_use.name)]
|
|
1459
|
+
rescue ToolError => error
|
|
1460
|
+
[tool_use, error]
|
|
1461
|
+
end
|
|
1462
|
+
tools = pairs.filter_map { |_tool_use, tool| tool if tool.is_a?(Tool) }
|
|
1463
|
+
execution = lambda do |tool_use, tool|
|
|
1464
|
+
started_at = monotonic_time
|
|
1465
|
+
operation_id = SecureRandom.uuid
|
|
1466
|
+
telemetry_tool_name = tool.is_a?(Tool) ? tool.tool_name : "unknown_tool"
|
|
1467
|
+
tool_handle = start_instrumentation(
|
|
1468
|
+
:tool,
|
|
1469
|
+
parent: parent_operation_id || active_instrumentation_parent,
|
|
1470
|
+
operation_id:,
|
|
1471
|
+
**parent_trace_attributes(parent_trace_context),
|
|
1472
|
+
tool_name: telemetry_tool_name,
|
|
1473
|
+
tool_type: "function",
|
|
1474
|
+
tool_call_id: tool_use.id,
|
|
1475
|
+
diagnostic: {
|
|
1476
|
+
input: diagnostic_tool_input(tool_use),
|
|
1477
|
+
tool_definitions: tool.is_a?(Tool) ? [tool.specification] : []
|
|
1478
|
+
}
|
|
1479
|
+
)
|
|
1480
|
+
if tool.is_a?(ToolError)
|
|
1481
|
+
result = build_tool_result(tool_use_id: tool_use.id, content: tool.message, status: :error)
|
|
1482
|
+
finish_instrumentation(
|
|
1483
|
+
tool_handle,
|
|
1484
|
+
operation_id:,
|
|
1485
|
+
parent_operation_id:,
|
|
1486
|
+
tool_name: telemetry_tool_name,
|
|
1487
|
+
outcome: :error,
|
|
1488
|
+
duration_ms: duration_ms(started_at),
|
|
1489
|
+
error_type: tool.class.name,
|
|
1490
|
+
diagnostic: {
|
|
1491
|
+
output: diagnostic_tool_result(result, tool:),
|
|
1492
|
+
exception: diagnostic_tool_exception(tool, tool:)
|
|
1493
|
+
}
|
|
1494
|
+
)
|
|
1495
|
+
next result
|
|
1496
|
+
end
|
|
1497
|
+
|
|
1498
|
+
context.check!
|
|
1499
|
+
|
|
1500
|
+
callback_payload = {
|
|
1501
|
+
tool_use: tool_use,
|
|
1502
|
+
tool: tool,
|
|
1503
|
+
operation_id: operation_id,
|
|
1504
|
+
parent_operation_id: parent_operation_id
|
|
1505
|
+
}
|
|
1506
|
+
decision = run_callbacks(:before_tool, callback_payload, context: context)
|
|
1507
|
+
if decision.cancel?
|
|
1508
|
+
rejection = ToolError.new(decision.reason)
|
|
1509
|
+
result = build_tool_result(
|
|
1510
|
+
tool_use_id: tool_use.id,
|
|
1511
|
+
content: decision.reason,
|
|
1512
|
+
status: :error
|
|
1513
|
+
)
|
|
1514
|
+
finish_instrumentation(
|
|
1515
|
+
tool_handle,
|
|
1516
|
+
operation_id:,
|
|
1517
|
+
parent_operation_id:,
|
|
1518
|
+
tool_name: telemetry_tool_name,
|
|
1519
|
+
outcome: :error,
|
|
1520
|
+
duration_ms: duration_ms(started_at),
|
|
1521
|
+
error_type: rejection.class.name,
|
|
1522
|
+
diagnostic: {
|
|
1523
|
+
output: diagnostic_tool_result(result, tool:),
|
|
1524
|
+
exception: diagnostic_tool_exception(rejection, tool:)
|
|
1525
|
+
}
|
|
1526
|
+
)
|
|
1527
|
+
next result
|
|
1528
|
+
end
|
|
1529
|
+
|
|
1530
|
+
execution_context = ToolExecution.new(
|
|
1531
|
+
tool_use:,
|
|
1532
|
+
tool:,
|
|
1533
|
+
context:,
|
|
1534
|
+
events:,
|
|
1535
|
+
operation_id:,
|
|
1536
|
+
parent_operation_id:,
|
|
1537
|
+
parent_trace_context:
|
|
1538
|
+
)
|
|
1539
|
+
invoke = lambda do
|
|
1540
|
+
with_tool_execution(execution_context) do
|
|
1541
|
+
invoke_tool(
|
|
1542
|
+
tool_use, tool, context,
|
|
1543
|
+
operation_id:, parent_operation_id:
|
|
1544
|
+
)
|
|
1545
|
+
end
|
|
1546
|
+
end
|
|
1547
|
+
tool_result = tool.exclusive? ? synchronize_exclusive_tools(&invoke) : invoke.call
|
|
1548
|
+
after_decision = run_callbacks(
|
|
1549
|
+
:after_tool,
|
|
1550
|
+
callback_payload.merge(result: tool_result),
|
|
1551
|
+
context: context
|
|
1552
|
+
)
|
|
1553
|
+
tool_result = replacement_value(after_decision, :result, tool_result)
|
|
1554
|
+
result = build_tool_result(
|
|
1555
|
+
tool_use_id: tool_use.id,
|
|
1556
|
+
content: tool_result.content,
|
|
1557
|
+
status: tool_result.status
|
|
1558
|
+
)
|
|
1559
|
+
tool_error = tool_result.error
|
|
1560
|
+
tool_error ||= ToolError.new(result.content) if result.status == :error
|
|
1561
|
+
finish_instrumentation(
|
|
1562
|
+
tool_handle,
|
|
1563
|
+
operation_id:,
|
|
1564
|
+
parent_operation_id:,
|
|
1565
|
+
tool_name: telemetry_tool_name,
|
|
1566
|
+
outcome: result.status,
|
|
1567
|
+
duration_ms: duration_ms(started_at),
|
|
1568
|
+
error_type: tool_error&.class&.name,
|
|
1569
|
+
diagnostic: {
|
|
1570
|
+
output: diagnostic_tool_result(result, tool:),
|
|
1571
|
+
exception: tool_error && diagnostic_tool_exception(tool_error, tool:)
|
|
1572
|
+
}.compact
|
|
1573
|
+
)
|
|
1574
|
+
result
|
|
1575
|
+
rescue ToolError => error
|
|
1576
|
+
result = build_tool_result(tool_use_id: tool_use.id, content: error.message, status: :error)
|
|
1577
|
+
finish_instrumentation(
|
|
1578
|
+
tool_handle,
|
|
1579
|
+
operation_id:,
|
|
1580
|
+
parent_operation_id:,
|
|
1581
|
+
tool_name: telemetry_tool_name,
|
|
1582
|
+
outcome: :error,
|
|
1583
|
+
duration_ms: duration_ms(started_at),
|
|
1584
|
+
error_type: error.class.name,
|
|
1585
|
+
diagnostic: {
|
|
1586
|
+
output: diagnostic_tool_result(result, tool:),
|
|
1587
|
+
exception: diagnostic_tool_exception(error, tool:)
|
|
1588
|
+
}
|
|
1589
|
+
)
|
|
1590
|
+
result
|
|
1591
|
+
rescue => error
|
|
1592
|
+
finish_instrumentation(
|
|
1593
|
+
tool_handle,
|
|
1594
|
+
operation_id:,
|
|
1595
|
+
parent_operation_id:,
|
|
1596
|
+
tool_name: telemetry_tool_name,
|
|
1597
|
+
outcome: :error,
|
|
1598
|
+
duration_ms: duration_ms(started_at),
|
|
1599
|
+
error_type: error.class.name,
|
|
1600
|
+
diagnostic: {exception: diagnostic_tool_exception(error, tool:)}
|
|
1601
|
+
)
|
|
1602
|
+
raise
|
|
1603
|
+
end
|
|
1604
|
+
if tools.any?(&:exclusive?)
|
|
1605
|
+
pairs.map do |tool_use, tool|
|
|
1606
|
+
result = execution.call(tool_use, tool)
|
|
1607
|
+
emit(events, :tool_stop, tool_use:, result:)
|
|
1608
|
+
result
|
|
1609
|
+
end
|
|
1610
|
+
else
|
|
1611
|
+
@executor.map(
|
|
1612
|
+
pairs,
|
|
1613
|
+
cancellation_token: context.cancellation_token,
|
|
1614
|
+
on_result: lambda do |index, result|
|
|
1615
|
+
emit(events, :tool_stop, tool_use: tool_uses.fetch(index), result:)
|
|
1616
|
+
end
|
|
1617
|
+
) do |tool_use, tool|
|
|
1618
|
+
execution.call(tool_use, tool)
|
|
1619
|
+
end
|
|
1620
|
+
end
|
|
1621
|
+
end
|
|
1622
|
+
|
|
1623
|
+
def invoke_tool(tool_use, tool, context, operation_id:, parent_operation_id:)
|
|
1624
|
+
tool.execute(tool_use.input, context:)
|
|
1625
|
+
end
|
|
1626
|
+
|
|
1627
|
+
def parent_trace_attributes(trace_context)
|
|
1628
|
+
return {} unless trace_context.is_a?(Hash) && !trace_context.empty?
|
|
1629
|
+
|
|
1630
|
+
{trace_context:}
|
|
1631
|
+
end
|
|
1632
|
+
|
|
1633
|
+
def synchronize_exclusive_tools(&block)
|
|
1634
|
+
if run
|
|
1635
|
+
run.synchronize_exclusive_tools(&block)
|
|
1636
|
+
else
|
|
1637
|
+
@exclusive_tools_mutex.synchronize(&block)
|
|
1638
|
+
end
|
|
1639
|
+
end
|
|
1640
|
+
|
|
1641
|
+
def build_tool_result(tool_use_id:, content:, status:)
|
|
1642
|
+
truncated = truncated_tool_result(content)
|
|
1643
|
+
Content::ToolResult.new(tool_use_id:, content: truncated.freeze, status:)
|
|
1644
|
+
end
|
|
1645
|
+
|
|
1646
|
+
def capture_structured_result(text, context)
|
|
1647
|
+
value = JSON.parse(text)
|
|
1648
|
+
capture_structured_result_value(value, context)
|
|
1649
|
+
rescue JSON::ParserError
|
|
1650
|
+
"Structured result is not valid JSON"
|
|
1651
|
+
end
|
|
1652
|
+
|
|
1653
|
+
def capture_structured_result_tool(tool_uses, result_tool_uses, context)
|
|
1654
|
+
if result_tool_uses.length > 1
|
|
1655
|
+
return "The model called the structured result tool more than once"
|
|
1656
|
+
end
|
|
1657
|
+
if tool_uses.length > 1
|
|
1658
|
+
return "The structured result tool must be the only tool call in its response"
|
|
1659
|
+
end
|
|
1660
|
+
|
|
1661
|
+
capture_structured_result_value(result_tool_uses.first.input, context)
|
|
1662
|
+
end
|
|
1663
|
+
|
|
1664
|
+
def capture_structured_result_value(value, context)
|
|
1665
|
+
configuration = self.class.result_schema
|
|
1666
|
+
schema_name = configuration.fetch(:name)
|
|
1667
|
+
validate_structured_result_limits!(value)
|
|
1668
|
+
errors = Tool::SchemaValidator.new(configuration.fetch(:schema)).validate(value)
|
|
1669
|
+
return "Structured result does not match its schema: #{errors.join("; ")}" unless errors.empty?
|
|
1670
|
+
|
|
1671
|
+
context.submit_structured_result(
|
|
1672
|
+
StructuredResult.new(schema_name:, value:)
|
|
1673
|
+
)
|
|
1674
|
+
nil
|
|
1675
|
+
rescue ToolError => error
|
|
1676
|
+
error.message
|
|
1677
|
+
end
|
|
1678
|
+
|
|
1679
|
+
def structured_result_tool_uses(tool_uses)
|
|
1680
|
+
return [] unless @structured_output_strategy&.tool?
|
|
1681
|
+
|
|
1682
|
+
tool_uses.select { |tool_use| tool_use.name == @structured_output_strategy.schema_name }
|
|
1683
|
+
end
|
|
1684
|
+
|
|
1685
|
+
def structured_result_tool_errors(tool_uses)
|
|
1686
|
+
tool_uses.map do |tool_use|
|
|
1687
|
+
build_tool_result(
|
|
1688
|
+
tool_use_id: tool_use.id,
|
|
1689
|
+
content: "The structured result was invalid. Submit it again using the required schema.",
|
|
1690
|
+
status: :error
|
|
1691
|
+
)
|
|
1692
|
+
end
|
|
1693
|
+
end
|
|
1694
|
+
|
|
1695
|
+
def validate_structured_result_limits!(value)
|
|
1696
|
+
nodes = 0
|
|
1697
|
+
stack = [[value, 1]]
|
|
1698
|
+
until stack.empty?
|
|
1699
|
+
child, depth = stack.pop
|
|
1700
|
+
nodes += 1
|
|
1701
|
+
if depth > MAX_STRUCTURED_RESULT_DEPTH
|
|
1702
|
+
raise ToolError, "Structured result exceeds the maximum nesting depth"
|
|
1703
|
+
end
|
|
1704
|
+
if nodes > MAX_STRUCTURED_RESULT_NODES
|
|
1705
|
+
raise ToolError, "Structured result exceeds the maximum complexity"
|
|
1706
|
+
end
|
|
1707
|
+
|
|
1708
|
+
case child
|
|
1709
|
+
when Hash
|
|
1710
|
+
child.each { |key, nested| stack << [key, depth + 1] << [nested, depth + 1] }
|
|
1711
|
+
when Array
|
|
1712
|
+
child.each { |nested| stack << [nested, depth + 1] }
|
|
1713
|
+
end
|
|
1714
|
+
end
|
|
1715
|
+
|
|
1716
|
+
if JSON.generate(value).bytesize > MAX_STRUCTURED_RESULT_BYTES
|
|
1717
|
+
raise ToolError, "Structured result exceeds the maximum serialized size"
|
|
1718
|
+
end
|
|
1719
|
+
rescue JSON::GeneratorError
|
|
1720
|
+
raise ToolError, "Structured result cannot be serialized"
|
|
1721
|
+
end
|
|
1722
|
+
|
|
1723
|
+
def redact_structured_result_message(message)
|
|
1724
|
+
Message.new(
|
|
1725
|
+
role: message.role,
|
|
1726
|
+
content: "[Structured result #{self.class.result_schema.fetch(:name)} redacted]",
|
|
1727
|
+
metadata: message.metadata
|
|
1728
|
+
)
|
|
1729
|
+
end
|
|
1730
|
+
|
|
1731
|
+
def redact_structured_result_tool_message(message)
|
|
1732
|
+
tool_uses = message.content.grep(Content::ToolUse).map do |tool_use|
|
|
1733
|
+
Content::ToolUse.new(id: tool_use.id, name: tool_use.name, input: {})
|
|
1734
|
+
end
|
|
1735
|
+
Message.new(role: message.role, content: tool_uses, metadata: message.metadata)
|
|
1736
|
+
end
|
|
1737
|
+
|
|
1738
|
+
def structured_result_diagnostic_message(message)
|
|
1739
|
+
return message unless @structured_output_strategy
|
|
1740
|
+
|
|
1741
|
+
tool_uses = message.content.grep(Content::ToolUse)
|
|
1742
|
+
if tool_uses.empty?
|
|
1743
|
+
redact_structured_result_message(message)
|
|
1744
|
+
elsif structured_result_tool_uses(tool_uses).empty?
|
|
1745
|
+
message
|
|
1746
|
+
else
|
|
1747
|
+
redact_structured_result_tool_message(message)
|
|
1748
|
+
end
|
|
1749
|
+
end
|
|
1750
|
+
|
|
1751
|
+
def publish_model_events(events, buffered_events, provider_response, response, repair:)
|
|
1752
|
+
redact = structured_result_terminal_response?(provider_response, repair:) ||
|
|
1753
|
+
structured_result_terminal_response?(response, repair:)
|
|
1754
|
+
buffered_events.each do |event|
|
|
1755
|
+
published = redact ? redact_structured_result_event(event, provider_response) : event
|
|
1756
|
+
events << published if published
|
|
1757
|
+
end
|
|
1758
|
+
end
|
|
1759
|
+
|
|
1760
|
+
def redact_structured_result_event(event, response)
|
|
1761
|
+
return if %i[text_delta reasoning_delta tool_call_delta].include?(event.type)
|
|
1762
|
+
|
|
1763
|
+
data = event.data
|
|
1764
|
+
if event.type == :message_stop
|
|
1765
|
+
data = data.merge(response: redact_structured_result_response(response))
|
|
1766
|
+
elsif event.type == :tool_call_stop && data[:tool_use]
|
|
1767
|
+
tool_use = data.fetch(:tool_use)
|
|
1768
|
+
data = data.merge(
|
|
1769
|
+
tool_use: Content::ToolUse.new(id: tool_use.id, name: tool_use.name, input: {})
|
|
1770
|
+
)
|
|
1771
|
+
end
|
|
1772
|
+
StreamEvent.build(event.type, **data)
|
|
1773
|
+
end
|
|
1774
|
+
|
|
1775
|
+
def structured_result_stream_response(provider_response, response, repair:)
|
|
1776
|
+
redact = structured_result_terminal_response?(provider_response, repair:) ||
|
|
1777
|
+
structured_result_terminal_response?(response, repair:)
|
|
1778
|
+
return response unless redact
|
|
1779
|
+
|
|
1780
|
+
redact_structured_result_response(response)
|
|
1781
|
+
end
|
|
1782
|
+
|
|
1783
|
+
def structured_result_terminal_response?(response, repair:)
|
|
1784
|
+
return false unless @structured_output_strategy
|
|
1785
|
+
return true if repair
|
|
1786
|
+
|
|
1787
|
+
tool_uses = response.message.content.grep(Content::ToolUse)
|
|
1788
|
+
return tool_uses.empty? if @structured_output_strategy.provider?
|
|
1789
|
+
|
|
1790
|
+
tool_uses.empty? || !structured_result_tool_uses(tool_uses).empty?
|
|
1791
|
+
end
|
|
1792
|
+
|
|
1793
|
+
def redact_structured_result_response(response)
|
|
1794
|
+
ModelResponse.new(
|
|
1795
|
+
message: redact_structured_result_payload_message(response.message),
|
|
1796
|
+
stop_reason: response.stop_reason,
|
|
1797
|
+
usage: response.usage,
|
|
1798
|
+
metadata: response.metadata
|
|
1799
|
+
)
|
|
1800
|
+
end
|
|
1801
|
+
|
|
1802
|
+
def redact_structured_result_payload_message(message)
|
|
1803
|
+
if message.content.any? { |block| block.is_a?(Content::ToolUse) }
|
|
1804
|
+
redact_structured_result_tool_message(message)
|
|
1805
|
+
else
|
|
1806
|
+
redact_structured_result_message(message)
|
|
1807
|
+
end
|
|
1808
|
+
end
|
|
1809
|
+
|
|
1810
|
+
def structured_result_repair_message
|
|
1811
|
+
requirement = if @structured_output_strategy&.tool?
|
|
1812
|
+
"Call #{@structured_output_strategy.schema_name} exactly once as your only tool call."
|
|
1813
|
+
else
|
|
1814
|
+
"Your final response must be JSON matching the configured output schema."
|
|
1815
|
+
end
|
|
1816
|
+
Message.new(
|
|
1817
|
+
role: :user,
|
|
1818
|
+
content: "#{requirement} You have one repair attempt. The previous structured result was invalid."
|
|
1819
|
+
)
|
|
1820
|
+
end
|
|
1821
|
+
|
|
1822
|
+
def instrument_structured_result_repair(operation_id, started_at, context, validation_status:)
|
|
1823
|
+
instrument(
|
|
1824
|
+
:structured_result,
|
|
1825
|
+
parent_operation_id: operation_id,
|
|
1826
|
+
schema_name: self.class.result_schema.fetch(:name),
|
|
1827
|
+
strategy: structured_output_strategy_name,
|
|
1828
|
+
validation_status:,
|
|
1829
|
+
repair_attempted: true,
|
|
1830
|
+
duration_ms: duration_ms(started_at),
|
|
1831
|
+
result_duration_ms: duration_ms(started_at),
|
|
1832
|
+
**usage_attributes(context.usage)
|
|
1833
|
+
)
|
|
1834
|
+
end
|
|
1835
|
+
|
|
1836
|
+
def complete_structured_result(
|
|
1837
|
+
response,
|
|
1838
|
+
messages,
|
|
1839
|
+
context,
|
|
1840
|
+
events,
|
|
1841
|
+
agent_handle:,
|
|
1842
|
+
turn_handle:,
|
|
1843
|
+
turn:,
|
|
1844
|
+
started_at:,
|
|
1845
|
+
repaired:
|
|
1846
|
+
)
|
|
1847
|
+
structured_result = context.structured_result
|
|
1848
|
+
raise ProtocolError, "Structured output completed without capturing a result" unless structured_result
|
|
1849
|
+
|
|
1850
|
+
result = RunResult.new(
|
|
1851
|
+
message: messages[-1],
|
|
1852
|
+
stop_reason: :structured_result,
|
|
1853
|
+
usage: context.usage,
|
|
1854
|
+
messages: messages.freeze,
|
|
1855
|
+
state: context.state,
|
|
1856
|
+
structured_result:
|
|
1857
|
+
)
|
|
1858
|
+
decision = run_callbacks(:after_invocation, {result: result}, context: context)
|
|
1859
|
+
apply_cancellation_decision!(decision)
|
|
1860
|
+
result = replacement_value(decision, :result, result)
|
|
1861
|
+
context.checkpoint(result.messages)
|
|
1862
|
+
instrument(
|
|
1863
|
+
:structured_result,
|
|
1864
|
+
schema_name: structured_result.schema_name,
|
|
1865
|
+
strategy: structured_output_strategy_name,
|
|
1866
|
+
validation_status: :valid,
|
|
1867
|
+
repair_attempted: repaired,
|
|
1868
|
+
duration_ms: duration_ms(started_at),
|
|
1869
|
+
result_duration_ms: duration_ms(started_at),
|
|
1870
|
+
**usage_attributes(result.usage)
|
|
1871
|
+
)
|
|
1872
|
+
finish_instrumentation(
|
|
1873
|
+
turn_handle,
|
|
1874
|
+
outcome: :completed,
|
|
1875
|
+
turn:
|
|
1876
|
+
)
|
|
1877
|
+
metadata = model.respond_to?(:metadata) ? model.metadata : {}
|
|
1878
|
+
finish_instrumentation(
|
|
1879
|
+
agent_handle,
|
|
1880
|
+
outcome: :completed,
|
|
1881
|
+
duration_ms: duration_ms(started_at),
|
|
1882
|
+
stop_reason: result.stop_reason,
|
|
1883
|
+
diagnostic: {output: diagnostic_message(result.message)},
|
|
1884
|
+
**usage_attributes(result.usage)
|
|
1885
|
+
)
|
|
1886
|
+
emit(events, :invocation_stop, result:, metadata:)
|
|
1887
|
+
result
|
|
1888
|
+
end
|
|
1889
|
+
|
|
1890
|
+
def raise_structured_result_error!(
|
|
1891
|
+
message,
|
|
1892
|
+
context,
|
|
1893
|
+
operation_id:,
|
|
1894
|
+
started_at:,
|
|
1895
|
+
validation_errors:,
|
|
1896
|
+
repair_attempted: true
|
|
1897
|
+
)
|
|
1898
|
+
configuration = self.class.result_schema
|
|
1899
|
+
instrument(
|
|
1900
|
+
:structured_result,
|
|
1901
|
+
parent_operation_id: operation_id,
|
|
1902
|
+
schema_name: configuration.fetch(:name),
|
|
1903
|
+
strategy: structured_output_strategy_name,
|
|
1904
|
+
validation_status: :failed,
|
|
1905
|
+
repair_attempted:,
|
|
1906
|
+
duration_ms: duration_ms(started_at),
|
|
1907
|
+
result_duration_ms: duration_ms(started_at),
|
|
1908
|
+
**usage_attributes(context.usage)
|
|
1909
|
+
)
|
|
1910
|
+
raise StructuredResultError.new(
|
|
1911
|
+
message,
|
|
1912
|
+
schema_name: configuration.fetch(:name),
|
|
1913
|
+
validation_errors:
|
|
1914
|
+
)
|
|
1915
|
+
end
|
|
1916
|
+
|
|
1917
|
+
def structured_output_strategy_name
|
|
1918
|
+
@structured_output_strategy&.tool? ? :tool : :provider
|
|
1919
|
+
end
|
|
1920
|
+
|
|
1921
|
+
def rendered_system_prompt(locals, invocation_paths)
|
|
1922
|
+
prompt = self.class.system_prompt
|
|
1923
|
+
return prompt.call(locals) if prompt.respond_to?(:call)
|
|
1924
|
+
return prompt if prompt
|
|
1925
|
+
template = self.class.system_template
|
|
1926
|
+
return DEFAULT_SYSTEM_PROMPT if instance_of?(Agent) && run && !template
|
|
1927
|
+
|
|
1928
|
+
template ||= "#{self.class.logical_path}/system" if run
|
|
1929
|
+
return nil unless template
|
|
1930
|
+
|
|
1931
|
+
@template_resolver.render(
|
|
1932
|
+
template,
|
|
1933
|
+
locals: locals,
|
|
1934
|
+
invocation_paths: invocation_paths
|
|
1935
|
+
)
|
|
1936
|
+
end
|
|
1937
|
+
|
|
1938
|
+
def default_template_resolver(paths)
|
|
1939
|
+
LittleGhost::PromptResolver.new(paths:)
|
|
1940
|
+
end
|
|
1941
|
+
|
|
1942
|
+
def apply_cancellation_decision!(decision)
|
|
1943
|
+
raise CancelledError, decision.reason if decision.cancel?
|
|
1944
|
+
end
|
|
1945
|
+
|
|
1946
|
+
def replacement_value(decision, key, fallback)
|
|
1947
|
+
return fallback unless decision.replace?
|
|
1948
|
+
|
|
1949
|
+
value = decision.value
|
|
1950
|
+
value.is_a?(Hash) ? value.fetch(key, fallback) : value
|
|
1951
|
+
end
|
|
1952
|
+
|
|
1953
|
+
def run_callbacks(name, payload, context: nil)
|
|
1954
|
+
self.class.callbacks.run(name, payload, context:, receiver: self)
|
|
1955
|
+
end
|
|
1956
|
+
|
|
1957
|
+
def emit(events, type, **data)
|
|
1958
|
+
events << StreamEvent.build(type, **data)
|
|
1959
|
+
end
|
|
1960
|
+
|
|
1961
|
+
def instrument(name, **attributes)
|
|
1962
|
+
attributes.delete(:diagnostic) unless self.class.capture_diagnostics
|
|
1963
|
+
values = correlation_attributes.merge(attributes.compact)
|
|
1964
|
+
Instrumentation.publish(name, **values)
|
|
1965
|
+
end
|
|
1966
|
+
|
|
1967
|
+
def start_instrumentation(name, **attributes)
|
|
1968
|
+
attributes.delete(:diagnostic) unless self.class.capture_diagnostics
|
|
1969
|
+
Instrumentation.start(name, **correlation_attributes.merge(attributes.compact))
|
|
1970
|
+
end
|
|
1971
|
+
|
|
1972
|
+
def active_instrumentation_parent
|
|
1973
|
+
current = Instrumentation.current
|
|
1974
|
+
current if current&.active?
|
|
1975
|
+
end
|
|
1976
|
+
|
|
1977
|
+
def finish_instrumentation(handle, **attributes)
|
|
1978
|
+
return unless handle
|
|
1979
|
+
|
|
1980
|
+
attributes.delete(:diagnostic) unless self.class.capture_diagnostics
|
|
1981
|
+
handle.finish(**correlation_attributes.merge(attributes.compact))
|
|
1982
|
+
end
|
|
1983
|
+
|
|
1984
|
+
def correlation_attributes
|
|
1985
|
+
return {agent_id: self.class.agent_id} unless run
|
|
1986
|
+
|
|
1987
|
+
{
|
|
1988
|
+
run_id: run.invocation.run_id,
|
|
1989
|
+
invocation_id: run.invocation.invocation_id,
|
|
1990
|
+
session_id: run.invocation.session_id,
|
|
1991
|
+
agent_id: self.class.agent_id
|
|
1992
|
+
}
|
|
1993
|
+
end
|
|
1994
|
+
|
|
1995
|
+
def model_attributes
|
|
1996
|
+
{
|
|
1997
|
+
model_id: model.respond_to?(:id) ? model.id : nil,
|
|
1998
|
+
model_role: model.respond_to?(:role) ? model.role : nil,
|
|
1999
|
+
model_provider: model.respond_to?(:provider_name) ? model.provider_name : model.class.name
|
|
2000
|
+
}.compact
|
|
2001
|
+
end
|
|
2002
|
+
|
|
2003
|
+
def response_attributes(response)
|
|
2004
|
+
metadata = response.metadata.to_h
|
|
2005
|
+
response_id = metadata[:id] || metadata["id"]
|
|
2006
|
+
response_model = metadata[:model] || metadata["model"]
|
|
2007
|
+
{
|
|
2008
|
+
response_id:,
|
|
2009
|
+
response_model:,
|
|
2010
|
+
finish_reasons: response.stop_reason ? [response.stop_reason.to_s] : nil
|
|
2011
|
+
}.compact
|
|
2012
|
+
end
|
|
2013
|
+
|
|
2014
|
+
def provider_error_attributes(error)
|
|
2015
|
+
status = error.respond_to?(:status) ? error.status : nil
|
|
2016
|
+
{http_response_status_code: status}.compact
|
|
2017
|
+
end
|
|
2018
|
+
|
|
2019
|
+
def usage_attributes(usage)
|
|
2020
|
+
usage.respond_to?(:to_h) ? usage.to_h : {}
|
|
2021
|
+
end
|
|
2022
|
+
|
|
2023
|
+
def model_output_event?(event)
|
|
2024
|
+
%i[text_delta reasoning_delta tool_call_start tool_call_delta].include?(event.type)
|
|
2025
|
+
end
|
|
2026
|
+
|
|
2027
|
+
def diagnostic_input(value)
|
|
2028
|
+
value.is_a?(Message) ? diagnostic_message(value) : value
|
|
2029
|
+
end
|
|
2030
|
+
|
|
2031
|
+
def diagnostic_tool_input(tool_use)
|
|
2032
|
+
tool_use.input
|
|
2033
|
+
end
|
|
2034
|
+
|
|
2035
|
+
def diagnostic_message(message)
|
|
2036
|
+
{
|
|
2037
|
+
role: message.role,
|
|
2038
|
+
content: message.content.map { |block| diagnostic_content(block) }
|
|
2039
|
+
}
|
|
2040
|
+
end
|
|
2041
|
+
|
|
2042
|
+
def diagnostic_content(block)
|
|
2043
|
+
case block
|
|
2044
|
+
when Content::Text
|
|
2045
|
+
{type: "text", text: block.text}
|
|
2046
|
+
when Content::Reasoning
|
|
2047
|
+
{type: "reasoning", text: block.text}
|
|
2048
|
+
when Content::Image
|
|
2049
|
+
{type: "image", media_type: block.media_type, bytes: block.data.bytesize}
|
|
2050
|
+
when Content::Document
|
|
2051
|
+
{type: "document", media_type: block.media_type, name: block.name, bytes: block.data.bytesize}
|
|
2052
|
+
when Content::ToolUse
|
|
2053
|
+
{type: "tool_use", id: block.id, name: block.name, input: diagnostic_tool_input(block)}
|
|
2054
|
+
when Content::ToolResult
|
|
2055
|
+
{
|
|
2056
|
+
type: "tool_result", tool_use_id: block.tool_use_id,
|
|
2057
|
+
content: diagnostic_tool_result(block), status: block.status
|
|
2058
|
+
}
|
|
2059
|
+
else
|
|
2060
|
+
block.to_s
|
|
2061
|
+
end
|
|
2062
|
+
end
|
|
2063
|
+
|
|
2064
|
+
def diagnostic_tool_result(result, **)
|
|
2065
|
+
value = result.respond_to?(:content) ? result.content : result
|
|
2066
|
+
return value.map { |block| block.respond_to?(:role) ? diagnostic_message(block) : block.to_s } if value.is_a?(Array)
|
|
2067
|
+
|
|
2068
|
+
value.respond_to?(:role) ? diagnostic_message(value) : value.to_s
|
|
2069
|
+
end
|
|
2070
|
+
|
|
2071
|
+
def diagnostic_tool_exception(error, tool:)
|
|
2072
|
+
diagnostic_exception(error).merge(message: truncated_tool_result(error.message))
|
|
2073
|
+
end
|
|
2074
|
+
|
|
2075
|
+
def diagnostic_exception(error)
|
|
2076
|
+
{
|
|
2077
|
+
type: error.class.name,
|
|
2078
|
+
message: error.message,
|
|
2079
|
+
stacktrace: Array(error.backtrace).join("\n")
|
|
2080
|
+
}
|
|
2081
|
+
end
|
|
2082
|
+
|
|
2083
|
+
def truncated_tool_result(content)
|
|
2084
|
+
Support::OutputTruncation.truncate_middle_with_token_budget(content, @max_tool_result_tokens).first
|
|
2085
|
+
end
|
|
2086
|
+
|
|
2087
|
+
def monotonic_time
|
|
2088
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
2089
|
+
end
|
|
2090
|
+
|
|
2091
|
+
def duration_ms(started_at)
|
|
2092
|
+
((monotonic_time - started_at) * 1_000).round(3)
|
|
2093
|
+
end
|
|
2094
|
+
|
|
2095
|
+
def duration_seconds(started_at)
|
|
2096
|
+
(monotonic_time - started_at).round(6)
|
|
2097
|
+
end
|
|
2098
|
+
end
|
|
2099
|
+
end
|
|
2100
|
+
|
|
2101
|
+
require_relative "agent/skills"
|
|
2102
|
+
require_relative "agent/tool_loop"
|
|
2103
|
+
require_relative "agent/context_management"
|
|
2104
|
+
require_relative "agent/delegation"
|
|
2105
|
+
|
|
2106
|
+
LittleGhost::Agent.include(
|
|
2107
|
+
LittleGhost::Agent::Delegation,
|
|
2108
|
+
LittleGhost::Agent::Skills,
|
|
2109
|
+
LittleGhost::Agent::ContextManagement,
|
|
2110
|
+
LittleGhost::Agent::ToolLoop
|
|
2111
|
+
)
|