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,448 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module LittleGhost
6
+ # Give an agent a validated way to call application code. Every tool declares a
7
+ # model-visible name, description, and input shape before implementing its
8
+ # operation.
9
+ #
10
+ # class TicketStatusTool < LittleGhost::Tool
11
+ # tool_name "ticket_status"
12
+ # description "Look up a support ticket's status."
13
+ # input_schema type: "object", properties: {
14
+ # ticket_id: {type: "string"}
15
+ # }, required: ["ticket_id"], additionalProperties: false
16
+ #
17
+ # def call(input)
18
+ # {ticket_id: input.fetch("ticket_id"), status: "waiting_on_customer"}
19
+ # end
20
+ # end
21
+ #
22
+ # result = TicketStatusTool.new.execute("ticket_id" => "SUP-481")
23
+ # result.success? # => true
24
+ # JSON.parse(result.content) # => {"ticket_id"=>"SUP-481", "status"=>"waiting_on_customer"}
25
+ #
26
+ # The class DSL produces the frozen specification sent to models. +execute+
27
+ # validates incoming arguments, invokes +call+, and normalizes strings,
28
+ # JSON-compatible collections, and other return values to model-facing text.
29
+ # Tool.define offers the same contract for an embedded implementation.
30
+ #
31
+ # A tool registry creates one instance per agent run and supplies a Binding for
32
+ # access to the agent, run, runtime, model, workspace, and sandbox. Mutable
33
+ # per-instance state therefore belongs to that run. Registries close tools that
34
+ # implement +close+; <tt>exclusive true</tt> serializes calls against every
35
+ # other exclusive tool in the same run.
36
+ #
37
+ # Validation and application ToolError failures become error results. A
38
+ # ToolError message is visible to the model and must be safe to disclose;
39
+ # unexpected exception messages are replaced with their class name.
40
+ # Cancellation, deadlines, and cleanup errors propagate instead of becoming
41
+ # ordinary tool output. The configured sandbox, not Tool itself, enforces
42
+ # filesystem and process isolation.
43
+ class Tool
44
+ # Supply run-scoped collaborators when tools are instantiated outside an agent.
45
+ # A binding can be copied with selected collaborators replaced.
46
+ #
47
+ # Tool instances expose the bound agent, run, runtime, model, workspace, and
48
+ # sandbox through matching accessors. ToolRegistry and Agent normally create
49
+ # bindings on behalf of application code.
50
+ class Binding
51
+ # Agent, run, runtime, model, workspace, and sandbox available to a tool.
52
+ attr_reader :agent, :run, :runtime, :model, :workspace, :sandbox
53
+
54
+ # Creates a binding from any available run-scoped collaborators.
55
+ def initialize(agent: nil, run: nil, runtime: nil, model: nil, workspace: nil, sandbox: nil)
56
+ @agent = agent
57
+ @run = run
58
+ @runtime = runtime
59
+ @model = model
60
+ @workspace = workspace
61
+ @sandbox = sandbox
62
+ end
63
+
64
+ # Copies the binding, replacing only the supplied collaborators.
65
+ def with(agent: self.agent, run: self.run, runtime: self.runtime, model: self.model,
66
+ workspace: self.workspace, sandbox: self.sandbox)
67
+ self.class.new(agent:, run:, runtime:, model:, workspace:, sandbox:)
68
+ end
69
+
70
+ # Instantiates each supplied tool class against this binding.
71
+ def build(*tool_classes)
72
+ tool_classes.flatten.map { |tool_class| tool_class.new(binding: self) }
73
+ end
74
+ end
75
+
76
+ # Report the caller-safe outcome of one tool execution.
77
+ # The result retains model-facing content, status, and the original exception
78
+ # for application-side inspection.
79
+ ExecutionResult = Data.define(:content, :status, :error) do # :nodoc:
80
+ def initialize(content:, status:, error: nil)
81
+ super
82
+ end
83
+
84
+ def success?
85
+ status == :success
86
+ end
87
+
88
+ def error?
89
+ status == :error
90
+ end
91
+ end
92
+
93
+ # Reports the caller-safe outcome of one tool execution. It keeps
94
+ # model-facing content and status beside the original exception retained for
95
+ # application-side inspection.
96
+ class ExecutionResult < Data # :doc:
97
+ ##
98
+ # :singleton-method: new
99
+ # :call-seq:
100
+ # new(content:, status:, error: nil) -> ExecutionResult
101
+ #
102
+ # Collects the model-facing and application-facing parts of one result.
103
+
104
+ ##
105
+ # :attr_reader: content
106
+ # The normalized, caller-safe text returned to the model.
107
+
108
+ ##
109
+ # :attr_reader: status
110
+ # Either +:success+ or +:error+.
111
+
112
+ ##
113
+ # :attr_reader: error
114
+ # The original exception for application-side inspection, when present.
115
+
116
+ ##
117
+ # :method: success?
118
+ # Indicates that +status+ is +:success+.
119
+
120
+ ##
121
+ # :method: error?
122
+ # Indicates that +status+ is +:error+.
123
+ end
124
+
125
+ extend Support::ClassAttributes
126
+
127
+ class_attribute :tool_name_value
128
+ class_attribute :description_value
129
+ class_attribute :input_schema_value
130
+ class_attribute :exclusive_value, default: false
131
+
132
+ class << self
133
+ # :call-seq:
134
+ # tool_name() -> String
135
+ # tool_name(value) -> value
136
+ #
137
+ # The model-visible tool name.
138
+ #
139
+ # Named classes derive a snake-cased default; passing +value+ replaces it.
140
+ def tool_name(*values)
141
+ return configured_name if values.empty?
142
+
143
+ self.tool_name_value = String(values.fetch(0)).freeze
144
+ end
145
+
146
+ # :call-seq:
147
+ # description() -> String
148
+ # description(value) -> value
149
+ #
150
+ # The model-visible description used to decide when the tool applies.
151
+ def description(*values)
152
+ return description_value if values.empty?
153
+
154
+ self.description_value = String(values.fetch(0)).freeze
155
+ end
156
+
157
+ # :call-seq:
158
+ # input_schema() -> Hash
159
+ # input_schema(schema) -> schema
160
+ #
161
+ # The frozen JSON Schema subset used to validate model input.
162
+ #
163
+ # Setting a non-Hash schema raises ArgumentError. Keys are normalized to
164
+ # strings and the entire value is deeply frozen.
165
+ def input_schema(*values)
166
+ return input_schema_value || {}.freeze if values.empty?
167
+
168
+ value = values.fetch(0)
169
+ raise ArgumentError, "input_schema must be a hash" unless value.is_a?(Hash)
170
+
171
+ self.input_schema_value = deep_freeze(value)
172
+ end
173
+
174
+ # :call-seq:
175
+ # exclusive() -> true or false
176
+ # exclusive(value) -> value
177
+ #
178
+ # Whether calls acquire the run-wide exclusive tool lock.
179
+ def exclusive(*values)
180
+ return !!exclusive_value if values.empty?
181
+
182
+ self.exclusive_value = !!values.fetch(0)
183
+ end
184
+
185
+ # Creates an anonymous Tool subclass backed by +implementation+.
186
+ # The block receives +input+ and may also accept the +context:+ keyword.
187
+ #
188
+ # tool = LittleGhost::Tool.define(
189
+ # name: "echo", description: "Echo text.",
190
+ # input_schema: {type: "object"}
191
+ # ) { |input| input.fetch("text") }
192
+ def define(name:, description:, input_schema: {}, &implementation)
193
+ raise ArgumentError, "A tool implementation block is required" unless implementation
194
+
195
+ Class.new(self) do
196
+ tool_name(name)
197
+ description(description)
198
+ input_schema(input_schema)
199
+
200
+ define_method(:call) do |input|
201
+ accepts_context = implementation.parameters.any? do |kind, parameter|
202
+ kind == :keyrest || (%i[key keyreq].include?(kind) && parameter == :context)
203
+ end
204
+ if accepts_context
205
+ implementation.call(input, context: context)
206
+ else
207
+ implementation.call(input)
208
+ end
209
+ end
210
+ end
211
+ end
212
+
213
+ # The frozen model-facing name, description, and input schema.
214
+ def specification
215
+ {
216
+ name: tool_name,
217
+ description: description,
218
+ input_schema: input_schema
219
+ }.freeze
220
+ end
221
+
222
+ private
223
+
224
+ def configured_name
225
+ return tool_name_value if tool_name_value
226
+
227
+ class_name = Module.instance_method(:name).bind_call(self)
228
+ return if class_name.nil?
229
+
230
+ class_name.split("::").last
231
+ .gsub(/([a-z\d])([A-Z])/, "\\1_\\2")
232
+ .downcase
233
+ end
234
+
235
+ def deep_freeze(value)
236
+ case value
237
+ when Hash
238
+ value.each_with_object({}) do |(key, child), result|
239
+ result[key.to_s.freeze] = deep_freeze(child)
240
+ end.freeze
241
+ when Array
242
+ value.map { |child| deep_freeze(child) }.freeze
243
+ else
244
+ value.freeze
245
+ end
246
+ end
247
+ end
248
+
249
+ # RunContext supplied to the current #execute call, or nil outside execution.
250
+ attr_reader :context
251
+
252
+ # Model-visible name declared by the tool class.
253
+ def tool_name = self.class.tool_name
254
+ # Model-visible description declared by the tool class.
255
+ def description = self.class.description
256
+ # Normalized JSON input schema declared by the tool class.
257
+ def input_schema = self.class.input_schema
258
+ # Frozen provider-facing tool specification.
259
+ def specification = self.class.specification
260
+ # Indicates whether calls use the run-wide exclusive-tool lock.
261
+ def exclusive? = self.class.exclusive
262
+
263
+ # Creates a tool with the run-scoped collaborators in +binding+.
264
+ def initialize(binding: Binding.new)
265
+ @binding = binding
266
+ @state = {}
267
+ end
268
+
269
+ # Bound agent, when the tool belongs to an agent run.
270
+ def agent = binding.agent
271
+ # Bound run, when available.
272
+ def run = binding.run
273
+ # Bound runtime, when available.
274
+ def runtime = binding.runtime
275
+ # Bound model, when available.
276
+ def model = binding.model
277
+ # Bound workspace, when available.
278
+ def workspace = binding.workspace
279
+ # Bound sandbox, when available.
280
+ def sandbox = binding.sandbox
281
+
282
+ # Validates +input+ and invokes the tool, returning an ExecutionResult.
283
+ #
284
+ # Cancellation, deadline, and cleanup exceptions remain control-flow
285
+ # exceptions. ToolError and unexpected failures become sanitized error
286
+ # results; unexpected exception messages are not exposed to the model.
287
+ def execute(input, context: RunContext.new)
288
+ context ||= RunContext.new
289
+ errors = SchemaValidator.new(self.class.input_schema).validate(input)
290
+ unless errors.empty?
291
+ message = "Invalid tool input: #{errors.join("; ")}"
292
+ return failure(message, error: ToolError.new(message))
293
+ end
294
+
295
+ success(sanitize(bound_for(context).call(input)))
296
+ rescue CancelledError, DeadlineExceededError, CleanupError
297
+ raise
298
+ rescue ToolError => error
299
+ failure(error.message, error:)
300
+ rescue => error
301
+ failure("Tool failed (#{error.class})", error:)
302
+ end
303
+
304
+ # Implements the model-requested operation.
305
+ #
306
+ # Subclasses must override this method. The current RunContext is available
307
+ # through +context+ while the call executes.
308
+ def call(_input)
309
+ raise NotImplementedError, "#{self.class} must implement #call"
310
+ end
311
+
312
+ protected
313
+
314
+ attr_reader :binding
315
+ attr_writer :context
316
+
317
+ private
318
+
319
+ def bound_for(context)
320
+ dup.tap { |tool| tool.context = context }
321
+ end
322
+
323
+ attr_reader :state
324
+
325
+ def sanitize(value)
326
+ case value
327
+ when String then value
328
+ when nil then ""
329
+ when Hash, Array then JSON.generate(value)
330
+ else value.to_s
331
+ end
332
+ rescue JSON::GeneratorError
333
+ raise ToolError, "Tool returned content that cannot be serialized"
334
+ end
335
+
336
+ def success(content)
337
+ ExecutionResult.new(content: content.freeze, status: :success)
338
+ end
339
+
340
+ def failure(content, error:)
341
+ ExecutionResult.new(content: content.freeze, status: :error, error:)
342
+ end
343
+
344
+ class SchemaValidator # :nodoc:
345
+ def initialize(schema)
346
+ @schema = schema
347
+ end
348
+
349
+ def validate(value)
350
+ errors = []
351
+ validate_value(@schema, value, "$", errors)
352
+ errors
353
+ end
354
+
355
+ private
356
+
357
+ def validate_value(schema, value, path, errors)
358
+ return unless schema.is_a?(Hash)
359
+
360
+ validate_type(schema["type"], value, path, errors)
361
+ validate_enum(schema["enum"], value, path, errors)
362
+ validate_number(schema, value, path, errors) if value.is_a?(Numeric)
363
+ validate_string(schema, value, path, errors) if value.is_a?(String)
364
+ validate_object(schema, value, path, errors) if value.is_a?(Hash)
365
+ validate_array(schema, value, path, errors) if value.is_a?(Array)
366
+ end
367
+
368
+ def validate_type(type, value, path, errors)
369
+ return if type.nil? || Array(type).any? { |candidate| type_matches?(candidate, value) }
370
+
371
+ errors << "#{path} must be #{Array(type).join(" or ")}"
372
+ end
373
+
374
+ def validate_enum(enum, value, path, errors)
375
+ return if enum.nil? || enum.include?(value)
376
+
377
+ errors << "#{path} must be one of #{enum.map(&:inspect).join(", ")}"
378
+ end
379
+
380
+ def validate_number(schema, value, path, errors)
381
+ minimum = schema["minimum"]
382
+ maximum = schema["maximum"]
383
+ errors << "#{path} must be at least #{minimum}" if minimum && value < minimum
384
+ errors << "#{path} must be at most #{maximum}" if maximum && value > maximum
385
+ end
386
+
387
+ def validate_object(schema, value, path, errors)
388
+ properties = schema.fetch("properties", {})
389
+ required = schema.fetch("required", [])
390
+
391
+ required.each do |key|
392
+ errors << "#{path}.#{key} is required" unless key?(value, key)
393
+ end
394
+
395
+ value.each do |key, child|
396
+ property_schema = properties[key.to_s]
397
+ if property_schema
398
+ validate_value(property_schema, child, "#{path}.#{key}", errors)
399
+ elsif schema["additionalProperties"] == false
400
+ errors << "#{path}.#{key} is not allowed"
401
+ elsif schema["additionalProperties"].is_a?(Hash)
402
+ validate_value(schema["additionalProperties"], child, "#{path}.#{key}", errors)
403
+ end
404
+ end
405
+ end
406
+
407
+ def validate_string(schema, value, path, errors)
408
+ minimum = schema["minLength"]
409
+ maximum = schema["maxLength"]
410
+ pattern = schema["pattern"]
411
+ errors << "#{path} must have at least #{minimum} characters" if minimum && value.length < minimum
412
+ errors << "#{path} must have at most #{maximum} characters" if maximum && value.length > maximum
413
+ errors << "#{path} has an invalid format" if pattern && !Regexp.new(pattern).match?(value)
414
+ rescue RegexpError
415
+ errors << "#{path} has an invalid schema pattern"
416
+ end
417
+
418
+ def validate_array(schema, value, path, errors)
419
+ minimum = schema["minItems"]
420
+ maximum = schema["maxItems"]
421
+ errors << "#{path} must contain at least #{minimum} items" if minimum && value.length < minimum
422
+ errors << "#{path} must contain at most #{maximum} items" if maximum && value.length > maximum
423
+ return unless schema["items"].is_a?(Hash)
424
+
425
+ value.each_with_index do |child, index|
426
+ validate_value(schema["items"], child, "#{path}[#{index}]", errors)
427
+ end
428
+ end
429
+
430
+ def type_matches?(type, value)
431
+ case type.to_s
432
+ when "object" then value.is_a?(Hash)
433
+ when "array" then value.is_a?(Array)
434
+ when "string" then value.is_a?(String)
435
+ when "integer" then value.is_a?(Integer)
436
+ when "number" then value.is_a?(Numeric)
437
+ when "boolean" then value == true || value == false
438
+ when "null" then value.nil?
439
+ else false
440
+ end
441
+ end
442
+
443
+ def key?(value, key)
444
+ value.key?(key) || value.key?(key.to_sym)
445
+ end
446
+ end
447
+ end
448
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LittleGhost
4
+ # ToolExecution gives runtime hooks one complete view of a tool call. It keeps
5
+ # the tool, call, run context, emitted events, and tracing relationship
6
+ # together while hooks prepare or observe execution.
7
+ #
8
+ # +events+ collects events emitted around the execution, while operation IDs
9
+ # and trace context relate nested tool work to its parent operation.
10
+ ToolExecution = Data.define( # :nodoc:
11
+ :tool_use,
12
+ :tool,
13
+ :context,
14
+ :events,
15
+ :operation_id,
16
+ :parent_operation_id,
17
+ :parent_trace_context
18
+ )
19
+
20
+ # Gives runtime hooks one complete view of a tool call while they prepare or
21
+ # observe its execution.
22
+ class ToolExecution < Data # :doc:
23
+ ##
24
+ # :singleton-method: new
25
+ # :call-seq:
26
+ # new(tool_use:, tool:, context:, events:, operation_id:,
27
+ # parent_operation_id:, parent_trace_context:) -> ToolExecution
28
+ #
29
+ # Collects one bound tool call for runtime hooks.
30
+
31
+ ##
32
+ # :attr_reader: tool_use
33
+ # The Content::ToolUse requested by the model.
34
+
35
+ ##
36
+ # :attr_reader: tool
37
+ # The bound Tool instance selected for the call.
38
+
39
+ ##
40
+ # :attr_reader: context
41
+ # The cooperative RunContext for this work.
42
+
43
+ ##
44
+ # :attr_reader: events
45
+ # Events collected around the tool execution.
46
+
47
+ ##
48
+ # :attr_reader: operation_id
49
+ # The instrumentation operation identifier for this call.
50
+
51
+ ##
52
+ # :attr_reader: parent_operation_id
53
+ # The parent instrumentation operation identifier, when present.
54
+
55
+ ##
56
+ # :attr_reader: parent_trace_context
57
+ # The trace context inherited from the parent operation, when present.
58
+ end
59
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LittleGhost
4
+ # ToolRegistry turns an agent's tool declarations into the exact set a model
5
+ # can call during one run. It validates names, binds run collaborators, and
6
+ # closes owned tool instances with the run.
7
+ #
8
+ # binding = LittleGhost::Tool::Binding.new
9
+ # registry = LittleGhost::ToolRegistry.new([HelpCenterLookupTool], binding:)
10
+ # registry.names # => ["policy_lookup"]
11
+ #
12
+ # Entries may be Tool instances, Tool subclasses, nested arrays, or provider
13
+ # classes that implement <tt>tools(binding)</tt>. Names must be unique, contain only
14
+ # letters, numbers, underscores, or hyphens, and be at most 64 characters.
15
+ # Owned tools that implement +close+ are closed once in reverse order.
16
+ class ToolRegistry
17
+ MAX_NAME_LENGTH = 64 # :nodoc:
18
+ NAME_PATTERN = /\A[a-zA-Z0-9_-]+\z/ # :nodoc:
19
+
20
+ include Enumerable
21
+
22
+ # Binds newly instantiated tools to +binding+.
23
+ def initialize(tools = [], binding: Tool::Binding.new)
24
+ @tools = {}
25
+ @binding = binding
26
+ @closed = false
27
+ @closed_tool_ids = {}
28
+ supplied_instances = Array(tools).flatten.grep(Tool).uniq(&:object_id)
29
+ add(tools)
30
+ rescue => error
31
+ begin
32
+ close
33
+ rescue
34
+ nil
35
+ end
36
+ begin
37
+ close_instances(supplied_instances)
38
+ rescue
39
+ nil
40
+ end
41
+ raise error
42
+ end
43
+
44
+ # Registers +tool+ and returns +self+. When +replace+ is true, replaced owned tools
45
+ # are closed after the new entries have been validated.
46
+ def register(tool, replace: false)
47
+ raise Error, "Tool registry is closed" if @closed
48
+
49
+ instances = []
50
+ existing_ids = @tools.each_value.to_h { |instance| [instance.object_id, true] }
51
+ resolve(tool, instances, binding: @binding)
52
+ seen = []
53
+ names = instances.map do |instance|
54
+ raise ConfigurationError, "Tools must inherit from LittleGhost::Tool" unless instance.is_a?(Tool)
55
+
56
+ name = instance.class.tool_name
57
+ validate_name!(name)
58
+ validate_description!(instance.class.description)
59
+ raise ConfigurationError, "Tool name collision: #{name}" if @tools.key?(name) && !replace
60
+ raise ConfigurationError, "Tool name collision: #{name}" if seen.include?(name)
61
+
62
+ seen << name
63
+ name
64
+ end
65
+
66
+ replaced = names.filter_map { |name| @tools[name] if replace }
67
+ names.zip(instances).each { |name, instance| @tools[name] = instance }
68
+ close_instances(replaced)
69
+ self
70
+ rescue => error
71
+ begin
72
+ close_instances(instances.to_a.reject { |instance| existing_ids&.key?(instance.object_id) })
73
+ rescue
74
+ nil
75
+ end
76
+ raise error
77
+ end
78
+
79
+ # Closes every owned tool that responds to +close+.
80
+ def close
81
+ return if @closed
82
+
83
+ @closed = true
84
+ close_instances(@tools.each_value.to_a)
85
+ end
86
+
87
+ # Finds the named tool or raises ToolError when it is unavailable.
88
+ def fetch(name)
89
+ @tools.fetch(name.to_s) { raise ToolError, "Unknown tool: #{name}" }
90
+ end
91
+
92
+ # Yields each registered tool instance.
93
+ def each(&block)
94
+ @tools.each_value(&block)
95
+ end
96
+
97
+ # Collects the frozen model-facing tool specifications.
98
+ def specifications
99
+ map { |tool| tool.class.specification }.freeze
100
+ end
101
+
102
+ # Lists the frozen model-visible tool names.
103
+ def names
104
+ @tools.keys.freeze
105
+ end
106
+
107
+ private
108
+
109
+ def add(values)
110
+ Array(values).flatten.compact.each { |value| register(value) }
111
+ end
112
+
113
+ def close_instances(instances)
114
+ first_error = nil
115
+ instances.reverse_each do |instance|
116
+ next unless instance.respond_to?(:close)
117
+ next if @closed_tool_ids[instance.object_id]
118
+
119
+ @closed_tool_ids[instance.object_id] = true
120
+ instance.close
121
+ rescue => error
122
+ first_error ||= error
123
+ end
124
+ raise first_error if first_error
125
+ end
126
+
127
+ def resolve(value, instances, binding:)
128
+ if value.is_a?(Array)
129
+ value.flatten.compact.each { |child| resolve(child, instances, binding:) }
130
+ elsif value.is_a?(Class) && value <= Tool
131
+ instances << value.new(binding:)
132
+ elsif value.is_a?(Class)
133
+ resolve(value.tools(binding), instances, binding:)
134
+ else
135
+ instances << value
136
+ end
137
+ instances
138
+ end
139
+
140
+ def validate_name!(name)
141
+ if name.nil? || name.empty?
142
+ raise ConfigurationError, "Tool name is required"
143
+ elsif name.length > MAX_NAME_LENGTH
144
+ raise ConfigurationError, "Tool name cannot exceed #{MAX_NAME_LENGTH} characters: #{name}"
145
+ elsif !NAME_PATTERN.match?(name)
146
+ raise ConfigurationError, "Tool name may contain only letters, numbers, underscores, and hyphens: #{name}"
147
+ end
148
+ end
149
+
150
+ def validate_description!(description)
151
+ if description.nil? || description.empty?
152
+ raise ConfigurationError, "Tool description is required"
153
+ end
154
+ end
155
+ end
156
+ end