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,396 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require "net/http"
6
+ require "uri"
7
+
8
+ module LittleGhost
9
+ # MCP lets LittleGhost agents use tools published by Model Context Protocol
10
+ # servers. Require +little_ghost/mcp+ to load the optional HTTP integration.
11
+ module MCP
12
+ # Model Context Protocol version negotiated by Client.
13
+ PROTOCOL_VERSION = "2025-06-18"
14
+
15
+ # HTTPTransport sends MCP JSON-RPC messages over Streamable HTTP. It applies
16
+ # time and response-size limits and keeps the negotiated MCP session ID.
17
+ #
18
+ # === Security and trust
19
+ #
20
+ # HTTPS is required by default. +allow_insecure_http+ is only for an
21
+ # explicitly trusted local development endpoint. Scope caller-supplied
22
+ # credential headers to the target server. Response bodies and negotiated
23
+ # session IDs are validated before use.
24
+ #
25
+ # One transport instance retains one negotiated MCP session ID and sends it
26
+ # with later requests. Scope the transport and its Client to one trusted
27
+ # server and one authenticated principal; never share that pair across
28
+ # tenants. LittleGhost does not send MCP session-termination DELETE requests,
29
+ # so configure server-side expiry or manage that lifecycle outside this
30
+ # transport when the server requires explicit cleanup.
31
+ class HTTPTransport
32
+ # Default upper bound for one MCP response body (10 MiB).
33
+ DEFAULT_MAX_RESPONSE_BYTES = 10 * 1024 * 1024
34
+ SESSION_ID_PATTERN = /\A[\x21-\x7e]{1,256}\z/ # :nodoc:
35
+
36
+ # Configures time and response-size limits. +signer+, when
37
+ # supplied, is called with each Net::HTTP request before it is sent.
38
+ def initialize(url:, headers: {}, timeout: 60, signer: nil, allow_insecure_http: false,
39
+ max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES)
40
+ @uri = URI(url)
41
+ unless %w[http https].include?(@uri.scheme) && @uri.host
42
+ raise ConfigurationError, "MCP URL must be an HTTP(S) URL"
43
+ end
44
+ if @uri.scheme == "http" && !allow_insecure_http
45
+ raise ConfigurationError, "MCP URL must use HTTPS unless allow_insecure_http is enabled"
46
+ end
47
+
48
+ @headers = headers.transform_keys(&:to_s).freeze
49
+ @timeout = Float(timeout)
50
+ raise ArgumentError, "timeout must be positive" unless @timeout.positive?
51
+
52
+ @max_response_bytes = Integer(max_response_bytes)
53
+ raise ArgumentError, "max_response_bytes must be positive" unless @max_response_bytes.positive?
54
+
55
+ @signer = signer
56
+ @session_id = nil
57
+ end
58
+
59
+ # Sends one JSON-RPC payload. A RunContext supplies cancellation and a
60
+ # deadline; without it the configured timeout applies.
61
+ def send(payload, context: nil)
62
+ return perform_send(payload, timeout: @timeout) unless context
63
+
64
+ response = nil
65
+ stream = Support::InterruptibleStream.new(
66
+ cancellation_token: context.cancellation_token,
67
+ deadline: context.deadline
68
+ ) do |emit|
69
+ emit.call(perform_send(payload, timeout: context.remaining_time(@timeout)))
70
+ end
71
+ stream.each { |value| response = value }
72
+ response
73
+ end
74
+
75
+ private
76
+
77
+ def perform_send(payload, timeout:)
78
+ request = Net::HTTP::Post.new(@uri)
79
+ @headers.each { |name, value| request[name] = value }
80
+ request["Accept"] ||= "application/json, text/event-stream"
81
+ request["Content-Type"] ||= "application/json"
82
+ request["MCP-Protocol-Version"] ||= PROTOCOL_VERSION
83
+ request["Mcp-Session-Id"] = @session_id if @session_id
84
+ request.body = JSON.generate(payload)
85
+ @signer&.call(request)
86
+
87
+ response = nil
88
+ response_body = +""
89
+ http(timeout).request(request) do |received|
90
+ response = received
91
+ validate_content_length!(received)
92
+ received.read_body do |chunk|
93
+ response_body << chunk
94
+ raise ProtocolError, "MCP response exceeded #{@max_response_bytes} bytes" if response_body.bytesize > @max_response_bytes
95
+ end
96
+ end
97
+ @session_id = validated_session_id(response["Mcp-Session-Id"]) if response["Mcp-Session-Id"]
98
+ unless response.is_a?(Net::HTTPSuccess) || response.is_a?(Net::HTTPAccepted)
99
+ raise ProtocolError, "MCP request failed with HTTP #{response.code}"
100
+ end
101
+
102
+ parse(response, response_body, payload[:id])
103
+ rescue JSON::ParserError => error
104
+ raise ProtocolError, "MCP returned invalid JSON: #{error.message}"
105
+ rescue SystemCallError, SocketError, Timeout::Error => error
106
+ raise ProviderError, "MCP transport failed: #{error.class}"
107
+ end
108
+
109
+ def http(timeout)
110
+ Net::HTTP.new(@uri.host, @uri.port).tap do |client|
111
+ client.use_ssl = @uri.scheme == "https"
112
+ client.open_timeout = timeout
113
+ client.read_timeout = timeout
114
+ client.write_timeout = timeout
115
+ end
116
+ end
117
+
118
+ def parse(response, body, expected_id)
119
+ return {} if body.empty?
120
+
121
+ if response["Content-Type"].to_s.include?("text/event-stream")
122
+ payloads = body.scan(/^data:\s*(.+)$/).flatten.reject { |data| data == "[DONE]" }
123
+ raise ProtocolError, "MCP event stream contained no response" if payloads.empty?
124
+
125
+ messages = payloads.map { |payload| JSON.parse(payload) }
126
+ messages.reverse.find { |message| expected_id.nil? || message["id"] == expected_id } || {}
127
+ else
128
+ JSON.parse(body)
129
+ end
130
+ end
131
+
132
+ def validate_content_length!(response)
133
+ content_length = response["Content-Length"]
134
+ if content_length && Integer(content_length, exception: false).to_i > @max_response_bytes
135
+ raise ProtocolError, "MCP response exceeded #{@max_response_bytes} bytes"
136
+ end
137
+ end
138
+
139
+ def validated_session_id(value)
140
+ return value if SESSION_ID_PATTERN.match?(value)
141
+
142
+ raise ProtocolError, "MCP server returned an invalid session ID"
143
+ end
144
+ end
145
+
146
+ # Client makes tools from an MCP server available as ordinary LittleGhost
147
+ # tools. Agents can use a remote capability without learning a second tool
148
+ # interface.
149
+ #
150
+ # transport = LittleGhost::MCP::HTTPTransport.new(url: "https://mcp.example/rpc")
151
+ # client = LittleGhost::MCP::Client.new(transport:, prefix: "docs")
152
+ # client.tools.map(&:tool_name) # => ["docs_search", "docs_fetch"]
153
+ #
154
+ # Tool names are normalized and checked for collisions. Pagination, tool
155
+ # count, and response sizes are limited; +rejected_tools+ and
156
+ # +definition_filter+ can enforce an application allowlist.
157
+ #
158
+ # Server definitions and results remain untrusted input. LittleGhost validates
159
+ # them before creating tools, but applications still decide which servers and
160
+ # capabilities an agent may use.
161
+ #
162
+ # A client and its transport represent one authenticated server session.
163
+ # Create a separate pair for each principal or trust boundary. Protocol
164
+ # initialization and subsequent requests through one client are serialized;
165
+ # do not share its transport with another client.
166
+ class Client
167
+ MAX_TOOL_NAME_LENGTH = 64 # :nodoc:
168
+ ALIAS_DIGEST_LENGTH = 12 # :nodoc:
169
+ DEFAULT_MAX_TOOLS = 1_000 # :nodoc:
170
+ DEFAULT_MAX_PAGES = 100 # :nodoc:
171
+
172
+ # Uses a transport that responds to +send+.
173
+ def initialize(
174
+ transport:,
175
+ name: "mcp",
176
+ prefix: nil,
177
+ rejected_tools: [],
178
+ definition_filter: nil,
179
+ max_tools: DEFAULT_MAX_TOOLS,
180
+ max_pages: DEFAULT_MAX_PAGES
181
+ )
182
+ if definition_filter && !definition_filter.respond_to?(:call)
183
+ raise ArgumentError, "definition_filter must respond to call"
184
+ end
185
+
186
+ @transport = transport
187
+ @name = String(name)
188
+ @prefix = prefix&.to_s
189
+ @rejected_tools = rejected_tools.map(&:to_s).freeze
190
+ @definition_filter = definition_filter
191
+ @max_tools = positive_integer(max_tools, :max_tools)
192
+ @max_pages = positive_integer(max_pages, :max_pages)
193
+ @request_id = 0
194
+ @mutex = Mutex.new
195
+ @initialization_mutex = Mutex.new
196
+ @source_names_mutex = Mutex.new
197
+ @source_names = {}
198
+ @initialized = false
199
+ end
200
+
201
+ # Negotiates the protocol when needed, then provides Tool instances for
202
+ # the server's current definitions.
203
+ def tools(context: nil)
204
+ @initialization_mutex.synchronize do
205
+ initialize_protocol(context:) unless @initialized
206
+ end
207
+ definitions = list_tool_definitions(context:)
208
+ definitions.filter_map do |definition|
209
+ source_name = definition_name(definition)
210
+ next if @rejected_tools.include?(source_name)
211
+ next if @definition_filter && !@definition_filter.call(definition)
212
+
213
+ build_tool(definition)
214
+ end
215
+ end
216
+
217
+ # Calls an exposed tool and produces text or serialized structured
218
+ # content. Server-declared errors raise ToolError.
219
+ def call(name, arguments, context: nil)
220
+ source_name = @source_names_mutex.synchronize { @source_names.fetch(name.to_s, name.to_s) }
221
+ result = request("tools/call", {name: source_name, arguments: arguments}, context:)
222
+ content = serialize_result(result)
223
+ raise ToolError, content if result["isError"]
224
+
225
+ content
226
+ end
227
+
228
+ private
229
+
230
+ def initialize_protocol(context:)
231
+ result = request("initialize", {
232
+ protocolVersion: PROTOCOL_VERSION,
233
+ capabilities: {},
234
+ clientInfo: {name: "little_ghost", version: LittleGhost::VERSION}
235
+ }, context:)
236
+ unless result["protocolVersion"].to_s == PROTOCOL_VERSION
237
+ raise ProtocolError, "MCP server did not negotiate protocol #{PROTOCOL_VERSION}"
238
+ end
239
+
240
+ notify("notifications/initialized", context:)
241
+ @initialized = true
242
+ end
243
+
244
+ def request(method, params = {}, context: nil)
245
+ expected_id, response = @mutex.synchronize do
246
+ @request_id += 1
247
+ [@request_id, @transport.send(
248
+ {jsonrpc: "2.0", id: @request_id, method: method, params: params},
249
+ context:
250
+ )]
251
+ end
252
+ raise ProtocolError, "MCP response must be an object" unless response.is_a?(Hash)
253
+ if (error = response["error"])
254
+ raise ProtocolError, "MCP response error must be an object" unless error.is_a?(Hash)
255
+
256
+ message = error["message"].to_s
257
+ raise ToolError, message.empty? ? "MCP request failed" : message
258
+ end
259
+ if response.key?("id") && response["id"] != expected_id
260
+ raise ProtocolError, "MCP response ID did not match its request"
261
+ end
262
+
263
+ result = response["result"]
264
+ raise ProtocolError, "MCP response did not include a result object" unless result.is_a?(Hash)
265
+
266
+ result
267
+ end
268
+
269
+ def notify(method, params = {}, context: nil)
270
+ @transport.send({jsonrpc: "2.0", method: method, params: params}, context:)
271
+ end
272
+
273
+ def list_tool_definitions(context:)
274
+ definitions = []
275
+ cursor = nil
276
+ seen_cursors = {}
277
+ pages = 0
278
+ loop do
279
+ pages += 1
280
+ raise ProtocolError, "MCP tools/list exceeded #{@max_pages} pages" if pages > @max_pages
281
+
282
+ result = request("tools/list", cursor ? {cursor: cursor} : {}, context:)
283
+ tools = result.fetch("tools", [])
284
+ raise ProtocolError, "MCP tools/list tools must be an array" unless tools.is_a?(Array)
285
+
286
+ definitions.concat(tools)
287
+ raise ProtocolError, "MCP tools/list exceeded #{@max_tools} tools" if definitions.length > @max_tools
288
+ cursor = result["nextCursor"]
289
+ break if cursor.nil? || cursor.empty?
290
+
291
+ raise ProtocolError, "MCP tools/list repeated a pagination cursor" if seen_cursors[cursor]
292
+
293
+ seen_cursors[cursor] = true
294
+ end
295
+ definitions
296
+ end
297
+
298
+ def build_tool(definition)
299
+ client = self
300
+ source_name = definition_name(definition)
301
+ exposed_name = safe_name([@prefix, source_name].compact.join("___"))
302
+ @source_names_mutex.synchronize do
303
+ existing = @source_names[exposed_name]
304
+ if existing && existing != source_name
305
+ raise ConfigurationError, "MCP tools #{existing.inspect} and #{source_name.inspect} map to #{exposed_name.inspect}"
306
+ end
307
+ @source_names[exposed_name] = source_name
308
+ end
309
+
310
+ Tool.define(
311
+ name: exposed_name,
312
+ description: present_description(definition["description"]),
313
+ input_schema: definition.fetch("inputSchema", {type: "object"})
314
+ ) { |input, context:| client.call(exposed_name, input, context:) }
315
+ end
316
+
317
+ def definition_name(definition)
318
+ raise ProtocolError, "MCP tool definition must be an object" unless definition.is_a?(Hash)
319
+
320
+ name = definition["name"]
321
+ raise ProtocolError, "MCP tool definition must include a name" unless name.is_a?(String) && !name.empty?
322
+
323
+ name
324
+ end
325
+
326
+ def safe_name(name)
327
+ normalized = name.gsub(/[^a-zA-Z0-9_-]/, "_")
328
+ raise ConfigurationError, "MCP tool name cannot be empty" if normalized.empty?
329
+ return normalized if normalized.length <= MAX_TOOL_NAME_LENGTH
330
+
331
+ digest = Digest::SHA256.hexdigest(normalized)[0, ALIAS_DIGEST_LENGTH]
332
+ prefix_length = MAX_TOOL_NAME_LENGTH - ALIAS_DIGEST_LENGTH - 1
333
+ "#{normalized[0, prefix_length]}_#{digest}"
334
+ end
335
+
336
+ def present_description(description)
337
+ value = description.to_s
338
+ value.empty? ? "MCP tool from #{@name}" : value
339
+ end
340
+
341
+ def serialize_content(content)
342
+ text = content.filter_map { |block| block["text"] if block["type"] == "text" }.join("\n")
343
+ return text unless text.empty?
344
+
345
+ JSON.generate(content)
346
+ end
347
+
348
+ def serialize_result(result)
349
+ return serialize_content(result.fetch("content", [])) unless result.key?("structuredContent")
350
+
351
+ structured = result["structuredContent"]
352
+ raise ProtocolError, "MCP structuredContent must be an object" unless structured.is_a?(Hash)
353
+
354
+ payload = {"structuredContent" => structured}
355
+ content = result.fetch("content", [])
356
+ payload["content"] = content unless content.empty?
357
+ JSON.generate(payload)
358
+ end
359
+
360
+ def positive_integer(value, name)
361
+ integer = Integer(value)
362
+ raise ArgumentError, "#{name} must be positive" unless integer.positive?
363
+
364
+ integer
365
+ end
366
+ end
367
+
368
+ # SigV4Signer adds AWS Signature Version 4 authentication to MCP requests.
369
+ # Requires the application-provided +aws-sigv4+ gem and uses its normal AWS
370
+ # credentials-provider chain unless one is supplied explicitly.
371
+ class SigV4Signer
372
+ # Configures signing for +service+ and +region+.
373
+ def initialize(service:, region:, credentials_provider: nil)
374
+ require "aws-sigv4"
375
+ @signer = Aws::Sigv4::Signer.new(
376
+ service: service,
377
+ region: region,
378
+ credentials_provider: credentials_provider
379
+ )
380
+ rescue LoadError
381
+ raise ConfigurationError, "MCP SigV4 signing requires the optional aws-sigv4 gem"
382
+ end
383
+
384
+ # Signs +request+ in place immediately before transport.
385
+ def call(request)
386
+ signature = @signer.sign_request(
387
+ http_method: request.method,
388
+ url: request.uri,
389
+ headers: request.to_hash.transform_values { |values| Array(values).join(",") },
390
+ body: request.body
391
+ )
392
+ signature.headers.each { |name, value| request[name] = value }
393
+ end
394
+ end
395
+ end
396
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Loads LittleGhost's optional Model Context Protocol client. Requiring
4
+ # +little_ghost+ alone does not load its HTTP integration.
5
+ require_relative "mcp/client"
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module LittleGhost
6
+ # A Message carries one participant's contribution to an agent conversation.
7
+ # Its content can combine text, attachments, tool activity, and model reasoning.
8
+ #
9
+ # Content is normalized into {Content}[rdoc-ref:LittleGhost::Content] blocks
10
+ # held in a frozen Array. Strings become Content::Text blocks, and hashes use
11
+ # the serialized content shape accepted by Content.normalize. Nested values
12
+ # supplied by the caller are retained rather than defensively copied.
13
+ #
14
+ # message = LittleGhost::Message.new(role: :user, content: "Hello")
15
+ # message.text # => "Hello"
16
+ class Message
17
+ # Participant roles accepted by Message.new.
18
+ ROLES = %i[system developer user assistant tool].freeze
19
+
20
+ # Participant role, normalized Content blocks, and application metadata.
21
+ attr_reader :role, :content, :metadata
22
+
23
+ # Creates a frozen message with a supported +role+, normalized +content+, and
24
+ # application-defined +metadata+. The content Array and metadata Hash are
25
+ # frozen, but nested caller-owned values are retained.
26
+ def initialize(role:, content:, metadata: {})
27
+ @role = role.to_sym
28
+ raise ArgumentError, "Unsupported message role: #{role.inspect}" unless ROLES.include?(@role)
29
+
30
+ blocks = if content.nil?
31
+ []
32
+ elsif content.is_a?(Array)
33
+ content
34
+ else
35
+ [content]
36
+ end
37
+ @content = blocks.map { |block| Content.normalize(block) }.freeze
38
+ @metadata = metadata.freeze
39
+ freeze
40
+ end
41
+
42
+ # Keeps +value+ when it is already a message, or creates a message from a
43
+ # hash with string or symbol keys.
44
+ def self.coerce(value)
45
+ return value if value.is_a?(self)
46
+
47
+ hash = value.transform_keys(&:to_sym)
48
+ new(**hash)
49
+ end
50
+
51
+ # Joins the visible text blocks without including reasoning or tool content.
52
+ def text
53
+ content.grep(Content::Text).map(&:text).join
54
+ end
55
+
56
+ # Removes Content::Reasoning blocks, or keeps +self+ when none are
57
+ # present.
58
+ def without_reasoning
59
+ remaining_content = content.reject { |block| block.is_a?(Content::Reasoning) }
60
+ return self if remaining_content.length == content.length
61
+
62
+ self.class.new(role:, content: remaining_content, metadata:)
63
+ end
64
+
65
+ # Produces the JSON-safe message representation.
66
+ def to_h
67
+ {"role" => role.to_s, "content" => content.map(&:to_h), "metadata" => metadata}
68
+ end
69
+
70
+ # Encodes #to_h as JSON, forwarding generator +arguments+.
71
+ def to_json(*arguments)
72
+ JSON.generate(to_h, *arguments)
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LittleGhost
4
+ # Model is the configured connection between an agent role and a provider. It
5
+ # keeps provider choice and defaults out of the agent class that uses them.
6
+ #
7
+ # It merges profile settings into every ModelRequest, validates attachment
8
+ # modalities declared in metadata, lets providers prepare capability-sensitive
9
+ # requests, and delegates the normalized stream to the provider.
10
+ class Model
11
+ IDENTITY_METADATA_KEYS = %w[provider model_id model_role].freeze # :nodoc:
12
+
13
+ # Provider object and name, provider model ID, default settings, normalized
14
+ # metadata, and logical application role.
15
+ attr_reader :provider, :provider_name, :id, :settings, :metadata, :role
16
+
17
+ # Wraps an object that responds to +stream+.
18
+ def initialize(provider:, provider_name:, id: nil, model: nil, settings: {}, metadata: {}, role: nil)
19
+ raise ArgumentError, "provider must respond to stream" unless provider.respond_to?(:stream)
20
+ raise ArgumentError, "provider_name is required" if provider_name.nil? || provider_name.to_s.empty?
21
+ raise ArgumentError, "model is required" if (id || model).nil? || (id || model).to_s.empty?
22
+
23
+ @provider = provider
24
+ @provider_name = provider_name.to_sym
25
+ @id = (id || model)&.to_s
26
+ @settings = settings.to_h.transform_keys(&:to_sym).freeze
27
+ @role = role&.to_s
28
+ profile_metadata = metadata.to_h.reject { |key, _value| IDENTITY_METADATA_KEYS.include?(key.to_s) }
29
+ @metadata = profile_metadata.merge(
30
+ provider: @provider_name,
31
+ model_id: @id,
32
+ model_role: @role
33
+ ).freeze
34
+ end
35
+
36
+ # Streams +request+ through the configured provider.
37
+ #
38
+ # Profile settings are defaults; settings on +request+ take precedence.
39
+ def stream(request, &block)
40
+ validate_input_modalities!(request)
41
+ configured_request = ModelRequest.new(
42
+ messages: request.messages,
43
+ tools: request.tools,
44
+ settings: settings.merge(request.settings),
45
+ output_schema: request.output_schema,
46
+ tool_choice: request.tool_choice,
47
+ required_capabilities: request.required_capabilities,
48
+ cancellation_token: request.cancellation_token,
49
+ deadline: request.deadline
50
+ )
51
+ if provider.respond_to?(:prepare_request)
52
+ configured_request = provider.prepare_request(configured_request, capabilities:)
53
+ end
54
+ provider.stream(configured_request, &block)
55
+ end
56
+
57
+ # Uses advertised provider capabilities, falling back to the permissive legacy
58
+ # contract for providers that do not advertise them.
59
+ def capabilities
60
+ @capabilities ||= if provider.respond_to?(:capabilities)
61
+ provider.capabilities(metadata:)
62
+ else
63
+ ModelCapabilities.legacy
64
+ end
65
+ end
66
+
67
+ private
68
+
69
+ def validate_input_modalities!(request)
70
+ supported = metadata[:input_modalities] || metadata["input_modalities"]
71
+ return unless supported
72
+
73
+ required = request.messages.flat_map do |message|
74
+ message.content.filter_map do |block|
75
+ case block
76
+ when Content::Image then "image"
77
+ when Content::Document then "file"
78
+ end
79
+ end
80
+ end.uniq
81
+ missing = required - Array(supported).map { |value| value.to_s.downcase }
82
+ return if missing.empty?
83
+
84
+ raise UnsupportedInputError,
85
+ "The selected model does not support #{missing.join(" and ")} attachments. Choose a compatible model or remove those attachments."
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LittleGhost
4
+ # ModelCapabilities tells LittleGhost which optional features a model can use.
5
+ # It keeps structured results and tool selection from relying on provider
6
+ # guesswork.
7
+ #
8
+ # A +nil+ +supported_parameters+ list means parameter support is not restricted.
9
+ # Use +ModelCapabilities.unknown+ when capability metadata is unavailable and
10
+ # callers should avoid assuming support.
11
+ ModelCapabilities = Data.define( # :nodoc:
12
+ :native_structured_output,
13
+ :tools,
14
+ :tool_choice,
15
+ :supported_parameters,
16
+ :known
17
+ ) do
18
+ # Creates an immutable capability description.
19
+ def initialize(
20
+ native_structured_output: false,
21
+ tools: false,
22
+ tool_choice: false,
23
+ supported_parameters: nil,
24
+ known: true
25
+ )
26
+ parameters = supported_parameters&.map(&:to_s)&.uniq&.freeze
27
+ super(
28
+ native_structured_output: !!native_structured_output,
29
+ tools: !!tools,
30
+ tool_choice: !!tool_choice,
31
+ supported_parameters: parameters,
32
+ known: !!known
33
+ )
34
+ end
35
+
36
+ def native_structured_output? = native_structured_output
37
+ def tools? = tools
38
+ def tool_choice? = tool_choice
39
+ def known? = known
40
+
41
+ # Checks whether any supplied parameter name is supported.
42
+ def supports_parameter?(*names)
43
+ return true unless supported_parameters
44
+
45
+ names.flatten.any? { |name| supported_parameters.include?(name.to_s) }
46
+ end
47
+
48
+ # Supplies the backwards-compatible capability set for legacy providers.
49
+ def self.legacy
50
+ new(native_structured_output: true, tools: true, tool_choice: true)
51
+ end
52
+
53
+ # Marks capability support as unknown.
54
+ def self.unknown
55
+ new(known: false)
56
+ end
57
+ end
58
+
59
+ # Describes the optional features a model can use. Providers expose this value
60
+ # so structured results and tool selection do not rely on provider guesswork.
61
+ # A +nil+ supported-parameter list means support is unrestricted.
62
+ class ModelCapabilities < Data # :doc:
63
+ ##
64
+ # :singleton-method: new
65
+ # :call-seq:
66
+ # new(native_structured_output: false, tools: false, tool_choice: false,
67
+ # supported_parameters: nil, known: true) -> ModelCapabilities
68
+ #
69
+ # Normalizes flags to booleans and stores unique parameter-name Strings in a
70
+ # frozen Array.
71
+
72
+ ##
73
+ # :attr_reader: native_structured_output
74
+ # Whether the model accepts a provider-native structured-output schema.
75
+
76
+ ##
77
+ # :attr_reader: tools
78
+ # Whether the model accepts tool definitions.
79
+
80
+ ##
81
+ # :attr_reader: tool_choice
82
+ # Whether the model accepts an explicit tool-selection policy.
83
+
84
+ ##
85
+ # :attr_reader: supported_parameters
86
+ # A frozen Array of provider parameter-name Strings, or +nil+ when support is
87
+ # unrestricted. Caller-supplied Strings may be retained rather than copied.
88
+
89
+ ##
90
+ # :attr_reader: known
91
+ # Whether this value represents known capability metadata.
92
+
93
+ ##
94
+ # :method: native_structured_output?
95
+ # Indicates whether provider-native structured output is available.
96
+
97
+ ##
98
+ # :method: tools?
99
+ # Indicates whether the model accepts tools.
100
+
101
+ ##
102
+ # :method: tool_choice?
103
+ # Indicates whether the model accepts an explicit tool choice.
104
+
105
+ ##
106
+ # :method: known?
107
+ # Indicates whether capability metadata is known.
108
+
109
+ ##
110
+ # :method: supports_parameter?
111
+ # :call-seq:
112
+ # supports_parameter?(*names) -> boolean
113
+ #
114
+ # Checks whether any supplied parameter name is supported. An unrestricted
115
+ # capability set supports every name.
116
+
117
+ ##
118
+ # :singleton-method: legacy
119
+ # Supplies the permissive capability set for providers that predate explicit
120
+ # capability reporting.
121
+
122
+ ##
123
+ # :singleton-method: unknown
124
+ # Marks capability support as unknown so callers avoid assuming support.
125
+ end
126
+ end