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.
Files changed (82) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +22 -0
  3. data/README.md +122 -0
  4. data/docs/guides/Core Concepts.md +203 -0
  5. data/docs/guides/Getting Started.md +187 -0
  6. data/lib/little_ghost/ag_ui/adapter.rb +194 -0
  7. data/lib/little_ghost/ag_ui.rb +5 -0
  8. data/lib/little_ghost/agent/context_management.rb +285 -0
  9. data/lib/little_ghost/agent/delegation.rb +128 -0
  10. data/lib/little_ghost/agent/skills.rb +96 -0
  11. data/lib/little_ghost/agent/tool_loop.rb +239 -0
  12. data/lib/little_ghost/agent.rb +2111 -0
  13. data/lib/little_ghost/agent_builder.rb +191 -0
  14. data/lib/little_ghost/agent_interruptions.rb +197 -0
  15. data/lib/little_ghost/configuration.rb +337 -0
  16. data/lib/little_ghost/content.rb +324 -0
  17. data/lib/little_ghost/default_model_registry.rb +71 -0
  18. data/lib/little_ghost/errors.rb +48 -0
  19. data/lib/little_ghost/events.rb +264 -0
  20. data/lib/little_ghost/execution_state.rb +58 -0
  21. data/lib/little_ghost/instrumentation.rb +475 -0
  22. data/lib/little_ghost/invocation.rb +285 -0
  23. data/lib/little_ghost/lookup.rb +37 -0
  24. data/lib/little_ghost/mcp/client.rb +396 -0
  25. data/lib/little_ghost/mcp.rb +5 -0
  26. data/lib/little_ghost/message.rb +75 -0
  27. data/lib/little_ghost/model.rb +88 -0
  28. data/lib/little_ghost/model_capabilities.rb +126 -0
  29. data/lib/little_ghost/model_registry.rb +173 -0
  30. data/lib/little_ghost/model_request.rb +107 -0
  31. data/lib/little_ghost/model_response.rb +48 -0
  32. data/lib/little_ghost/path_set.rb +32 -0
  33. data/lib/little_ghost/prompt_resolver.rb +251 -0
  34. data/lib/little_ghost/providers/bedrock.rb +506 -0
  35. data/lib/little_ghost/providers/http_transport.rb +149 -0
  36. data/lib/little_ghost/providers/open_router.rb +171 -0
  37. data/lib/little_ghost/providers/openai.rb +27 -0
  38. data/lib/little_ghost/providers/openai_compatible.rb +745 -0
  39. data/lib/little_ghost/providers/sse_parser.rb +35 -0
  40. data/lib/little_ghost/run.rb +607 -0
  41. data/lib/little_ghost/run_context.rb +129 -0
  42. data/lib/little_ghost/run_result.rb +111 -0
  43. data/lib/little_ghost/runtime/hook.rb +31 -0
  44. data/lib/little_ghost/runtime.rb +392 -0
  45. data/lib/little_ghost/sandbox.rb +138 -0
  46. data/lib/little_ghost/session.rb +229 -0
  47. data/lib/little_ghost/session_store.rb +96 -0
  48. data/lib/little_ghost/session_stores/agent_core_memory.rb +1086 -0
  49. data/lib/little_ghost/session_stores/memory.rb +86 -0
  50. data/lib/little_ghost/skills/catalog.rb +283 -0
  51. data/lib/little_ghost/skills/skill.rb +60 -0
  52. data/lib/little_ghost/skills.rb +4 -0
  53. data/lib/little_ghost/stream_event.rb +49 -0
  54. data/lib/little_ghost/structured_output.rb +126 -0
  55. data/lib/little_ghost/subagents/agent_path.rb +63 -0
  56. data/lib/little_ghost/subagents/definition.rb +42 -0
  57. data/lib/little_ghost/subagents/manager.rb +1615 -0
  58. data/lib/little_ghost/support/callbacks.rb +151 -0
  59. data/lib/little_ghost/support/cancellation_token.rb +86 -0
  60. data/lib/little_ghost/support/class_attributes.rb +40 -0
  61. data/lib/little_ghost/support/content_capture.rb +150 -0
  62. data/lib/little_ghost/support/executor.rb +75 -0
  63. data/lib/little_ghost/support/interruptible_stream.rb +103 -0
  64. data/lib/little_ghost/support/loader.rb +263 -0
  65. data/lib/little_ghost/support/output_truncation.rb +71 -0
  66. data/lib/little_ghost/support/redactor.rb +66 -0
  67. data/lib/little_ghost/support.rb +34 -0
  68. data/lib/little_ghost/tool.rb +448 -0
  69. data/lib/little_ghost/tool_execution.rb +59 -0
  70. data/lib/little_ghost/tool_registry.rb +156 -0
  71. data/lib/little_ghost/tools/filesystem.rb +119 -0
  72. data/lib/little_ghost/tools/shell.rb +45 -0
  73. data/lib/little_ghost/tools/write_todos.rb +91 -0
  74. data/lib/little_ghost/tools.rb +6 -0
  75. data/lib/little_ghost/tracing/open_telemetry.rb +517 -0
  76. data/lib/little_ghost/unrestricted_sandbox.rb +306 -0
  77. data/lib/little_ghost/usage.rb +47 -0
  78. data/lib/little_ghost/version.rb +6 -0
  79. data/lib/little_ghost/workflow.rb +351 -0
  80. data/lib/little_ghost/workspace.rb +31 -0
  81. data/lib/little_ghost.rb +120 -0
  82. 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
+ )