little_ghost 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.txt +22 -0
- data/README.md +122 -0
- data/docs/guides/Core Concepts.md +203 -0
- data/docs/guides/Getting Started.md +187 -0
- data/lib/little_ghost/ag_ui/adapter.rb +194 -0
- data/lib/little_ghost/ag_ui.rb +5 -0
- data/lib/little_ghost/agent/context_management.rb +285 -0
- data/lib/little_ghost/agent/delegation.rb +128 -0
- data/lib/little_ghost/agent/skills.rb +96 -0
- data/lib/little_ghost/agent/tool_loop.rb +239 -0
- data/lib/little_ghost/agent.rb +2111 -0
- data/lib/little_ghost/agent_builder.rb +191 -0
- data/lib/little_ghost/agent_interruptions.rb +197 -0
- data/lib/little_ghost/configuration.rb +337 -0
- data/lib/little_ghost/content.rb +324 -0
- data/lib/little_ghost/default_model_registry.rb +71 -0
- data/lib/little_ghost/errors.rb +48 -0
- data/lib/little_ghost/events.rb +264 -0
- data/lib/little_ghost/execution_state.rb +58 -0
- data/lib/little_ghost/instrumentation.rb +475 -0
- data/lib/little_ghost/invocation.rb +285 -0
- data/lib/little_ghost/lookup.rb +37 -0
- data/lib/little_ghost/mcp/client.rb +396 -0
- data/lib/little_ghost/mcp.rb +5 -0
- data/lib/little_ghost/message.rb +75 -0
- data/lib/little_ghost/model.rb +88 -0
- data/lib/little_ghost/model_capabilities.rb +126 -0
- data/lib/little_ghost/model_registry.rb +173 -0
- data/lib/little_ghost/model_request.rb +107 -0
- data/lib/little_ghost/model_response.rb +48 -0
- data/lib/little_ghost/path_set.rb +32 -0
- data/lib/little_ghost/prompt_resolver.rb +251 -0
- data/lib/little_ghost/providers/bedrock.rb +506 -0
- data/lib/little_ghost/providers/http_transport.rb +149 -0
- data/lib/little_ghost/providers/open_router.rb +171 -0
- data/lib/little_ghost/providers/openai.rb +27 -0
- data/lib/little_ghost/providers/openai_compatible.rb +745 -0
- data/lib/little_ghost/providers/sse_parser.rb +35 -0
- data/lib/little_ghost/run.rb +607 -0
- data/lib/little_ghost/run_context.rb +129 -0
- data/lib/little_ghost/run_result.rb +111 -0
- data/lib/little_ghost/runtime/hook.rb +31 -0
- data/lib/little_ghost/runtime.rb +392 -0
- data/lib/little_ghost/sandbox.rb +138 -0
- data/lib/little_ghost/session.rb +229 -0
- data/lib/little_ghost/session_store.rb +96 -0
- data/lib/little_ghost/session_stores/agent_core_memory.rb +1086 -0
- data/lib/little_ghost/session_stores/memory.rb +86 -0
- data/lib/little_ghost/skills/catalog.rb +283 -0
- data/lib/little_ghost/skills/skill.rb +60 -0
- data/lib/little_ghost/skills.rb +4 -0
- data/lib/little_ghost/stream_event.rb +49 -0
- data/lib/little_ghost/structured_output.rb +126 -0
- data/lib/little_ghost/subagents/agent_path.rb +63 -0
- data/lib/little_ghost/subagents/definition.rb +42 -0
- data/lib/little_ghost/subagents/manager.rb +1615 -0
- data/lib/little_ghost/support/callbacks.rb +151 -0
- data/lib/little_ghost/support/cancellation_token.rb +86 -0
- data/lib/little_ghost/support/class_attributes.rb +40 -0
- data/lib/little_ghost/support/content_capture.rb +150 -0
- data/lib/little_ghost/support/executor.rb +75 -0
- data/lib/little_ghost/support/interruptible_stream.rb +103 -0
- data/lib/little_ghost/support/loader.rb +263 -0
- data/lib/little_ghost/support/output_truncation.rb +71 -0
- data/lib/little_ghost/support/redactor.rb +66 -0
- data/lib/little_ghost/support.rb +34 -0
- data/lib/little_ghost/tool.rb +448 -0
- data/lib/little_ghost/tool_execution.rb +59 -0
- data/lib/little_ghost/tool_registry.rb +156 -0
- data/lib/little_ghost/tools/filesystem.rb +119 -0
- data/lib/little_ghost/tools/shell.rb +45 -0
- data/lib/little_ghost/tools/write_todos.rb +91 -0
- data/lib/little_ghost/tools.rb +6 -0
- data/lib/little_ghost/tracing/open_telemetry.rb +517 -0
- data/lib/little_ghost/unrestricted_sandbox.rb +306 -0
- data/lib/little_ghost/usage.rb +47 -0
- data/lib/little_ghost/version.rb +6 -0
- data/lib/little_ghost/workflow.rb +351 -0
- data/lib/little_ghost/workspace.rb +31 -0
- data/lib/little_ghost.rb +120 -0
- metadata +225 -0
|
@@ -0,0 +1,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
|