little_ghost 0.3.0 → 0.5.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 (122) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +90 -84
  3. data/docs/guides/assemblies.md +412 -0
  4. data/docs/guides/code_mode.md +275 -0
  5. data/docs/guides/core_concepts.md +150 -239
  6. data/docs/guides/getting_started.md +125 -87
  7. data/docs/guides/integrations.md +217 -0
  8. data/docs/guides/models_and_providers.md +125 -0
  9. data/docs/guides/production.md +253 -0
  10. data/docs/guides/prompt_views.md +139 -0
  11. data/docs/guides/sandboxing.md +282 -0
  12. data/docs/guides/skills.md +141 -0
  13. data/docs/guides/structured_outputs_and_content.md +135 -0
  14. data/docs/guides/tools.md +329 -0
  15. data/lib/little_ghost/ag_ui/adapter.rb +5 -5
  16. data/lib/little_ghost/agent/delegation.rb +3 -3
  17. data/lib/little_ghost/agent/skills.rb +6 -1
  18. data/lib/little_ghost/agent/tool_loop.rb +6 -2
  19. data/lib/little_ghost/agent.rb +411 -214
  20. data/lib/little_ghost/agent_builder.rb +61 -22
  21. data/lib/little_ghost/{agent_interruptions.rb → agent_interjections.rb} +12 -12
  22. data/lib/little_ghost/agent_stream_source.rb +262 -0
  23. data/lib/little_ghost/artifact.rb +182 -0
  24. data/lib/little_ghost/artifacts/presentation_budget.rb +44 -0
  25. data/lib/little_ghost/artifacts/workspace_store.rb +370 -0
  26. data/lib/little_ghost/artifacts.rb +11 -0
  27. data/lib/little_ghost/assembly.rb +110 -30
  28. data/lib/little_ghost/assembly_builder.rb +59 -21
  29. data/lib/little_ghost/assembly_execution.rb +110 -6
  30. data/lib/little_ghost/code_mode/broker.rb +164 -0
  31. data/lib/little_ghost/code_mode/catalog.rb +54 -0
  32. data/lib/little_ghost/code_mode/engine.rb +58 -0
  33. data/lib/little_ghost/code_mode/javascript/catalog.rb +118 -0
  34. data/lib/little_ghost/code_mode/javascript/client.rb +550 -0
  35. data/lib/little_ghost/code_mode/javascript/host.rb +347 -0
  36. data/lib/little_ghost/code_mode/javascript/session.rb +579 -0
  37. data/lib/little_ghost/code_mode/javascript_engine.rb +172 -0
  38. data/lib/little_ghost/code_mode/protocol.rb +78 -0
  39. data/lib/little_ghost/code_mode/ruby/catalog.rb +80 -0
  40. data/lib/little_ghost/code_mode/ruby/host.rb +102 -0
  41. data/lib/little_ghost/code_mode/ruby/session.rb +508 -0
  42. data/lib/little_ghost/code_mode/ruby_engine.rb +86 -0
  43. data/lib/little_ghost/code_mode/runtime.rb +335 -0
  44. data/lib/little_ghost/code_mode/session.rb +61 -0
  45. data/lib/little_ghost/code_mode/types.rb +58 -0
  46. data/lib/little_ghost/code_mode.rb +53 -0
  47. data/lib/little_ghost/configuration.rb +366 -47
  48. data/lib/little_ghost/content.rb +24 -13
  49. data/lib/little_ghost/data_map.rb +209 -0
  50. data/lib/little_ghost/errors.rb +28 -9
  51. data/lib/little_ghost/execution.rb +33 -34
  52. data/lib/little_ghost/graph.rb +376 -197
  53. data/lib/little_ghost/mcp/client.rb +487 -88
  54. data/lib/little_ghost/mcp/toolset.rb +210 -0
  55. data/lib/little_ghost/mcp/types.rb +216 -0
  56. data/lib/little_ghost/mcp.rb +3 -0
  57. data/lib/little_ghost/message.rb +4 -4
  58. data/lib/little_ghost/model_capabilities.rb +9 -6
  59. data/lib/little_ghost/model_request.rb +0 -12
  60. data/lib/little_ghost/model_resolver.rb +15 -5
  61. data/lib/little_ghost/model_response.rb +3 -7
  62. data/lib/little_ghost/network/authorizer_server.rb +162 -0
  63. data/lib/little_ghost/network/certificate_authority.rb +98 -0
  64. data/lib/little_ghost/network/envoy_config.rb +362 -0
  65. data/lib/little_ghost/network/envoy_gateway.rb +409 -0
  66. data/lib/little_ghost/network/external_gateway.rb +68 -0
  67. data/lib/little_ghost/network.rb +96 -0
  68. data/lib/little_ghost/prompt_resolver.rb +9 -7
  69. data/lib/little_ghost/provider_registry.rb +3 -3
  70. data/lib/little_ghost/providers/anthropic.rb +8 -1
  71. data/lib/little_ghost/providers/gemini.rb +10 -1
  72. data/lib/little_ghost/providers/vertex_ai.rb +6 -1
  73. data/lib/little_ghost/run.rb +162 -54
  74. data/lib/little_ghost/run_context.rb +42 -20
  75. data/lib/little_ghost/run_result.rb +0 -7
  76. data/lib/little_ghost/runtime/hook.rb +8 -3
  77. data/lib/little_ghost/runtime/hooks/artifacts.rb +338 -0
  78. data/lib/little_ghost/runtime.rb +160 -52
  79. data/lib/little_ghost/sandbox/capabilities.rb +68 -0
  80. data/lib/little_ghost/sandbox/environment_policy.rb +41 -0
  81. data/lib/little_ghost/sandbox/filesystem.rb +377 -0
  82. data/lib/little_ghost/sandbox/isolated_backend.rb +163 -0
  83. data/lib/little_ghost/sandbox/limits.rb +48 -0
  84. data/lib/little_ghost/sandbox/mount.rb +129 -0
  85. data/lib/little_ghost/sandbox/network_policy.rb +108 -0
  86. data/lib/little_ghost/sandbox/policy.rb +88 -0
  87. data/lib/little_ghost/sandbox/process_runner.rb +103 -0
  88. data/lib/little_ghost/sandbox/process_session.rb +335 -0
  89. data/lib/little_ghost/sandbox/scope.rb +304 -0
  90. data/lib/little_ghost/sandbox.rb +158 -32
  91. data/lib/little_ghost/sandboxes/bubblewrap.rb +351 -0
  92. data/lib/little_ghost/sandboxes/native.rb +51 -0
  93. data/lib/little_ghost/sandboxes/seatbelt.rb +261 -0
  94. data/lib/little_ghost/sandboxes/unrestricted.rb +241 -0
  95. data/lib/little_ghost/session.rb +39 -26
  96. data/lib/little_ghost/session_store.rb +9 -5
  97. data/lib/little_ghost/session_stores/agent_core_memory.rb +64 -56
  98. data/lib/little_ghost/session_stores/filesystem.rb +261 -0
  99. data/lib/little_ghost/session_stores/memory.rb +7 -0
  100. data/lib/little_ghost/skills/catalog.rb +94 -14
  101. data/lib/little_ghost/skills/resource_root.rb +42 -0
  102. data/lib/little_ghost/skills/skill.rb +0 -3
  103. data/lib/little_ghost/stream_event.rb +8 -13
  104. data/lib/little_ghost/subagents/control_tool.rb +8 -0
  105. data/lib/little_ghost/subagents/manager.rb +53 -50
  106. data/lib/little_ghost/support/callbacks.rb +3 -1
  107. data/lib/little_ghost/support/content_capture.rb +3 -3
  108. data/lib/little_ghost/support/http_client.rb +2 -2
  109. data/lib/little_ghost/support/redactor.rb +1 -1
  110. data/lib/little_ghost/swarm.rb +13 -5
  111. data/lib/little_ghost/tool.rb +157 -64
  112. data/lib/little_ghost/tool_registry.rb +1 -1
  113. data/lib/little_ghost/tools/filesystem.rb +1 -1
  114. data/lib/little_ghost/tools/shell.rb +4 -3
  115. data/lib/little_ghost/tools/write_todos.rb +6 -1
  116. data/lib/little_ghost/tracing/open_telemetry.rb +1 -1
  117. data/lib/little_ghost/version.rb +1 -1
  118. data/lib/little_ghost/workflow.rb +30 -21
  119. data/lib/little_ghost/workspace.rb +222 -8
  120. data/lib/little_ghost.rb +40 -27
  121. metadata +104 -3
  122. data/lib/little_ghost/unrestricted_sandbox.rb +0 -306
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LittleGhost
4
+ module MCP
5
+ # Connects one MCP server to an Agent as a reusable Tool provider. Each Agent
6
+ # run gets its own connection. +map_tool+ chooses and configures the generated
7
+ # Tool classes; +map_result+ converts results that need application-specific
8
+ # handling.
9
+ #
10
+ # class HelpCenterTools < LittleGhost::MCP::Toolset
11
+ # connection url: "https://mcp.example/rpc", timeout: 20
12
+ # end
13
+ #
14
+ # class CustomerSupportAgent < LittleGhost::Agent
15
+ # tools HelpCenterTools
16
+ # end
17
+ class Toolset
18
+ extend Support::ClassAttributes
19
+
20
+ CONNECTION_OPTIONS = %i[url headers timeout signer allow_insecure_http max_response_bytes].freeze # :nodoc:
21
+ UNSET = Object.new.freeze # :nodoc:
22
+
23
+ class CallbackFailure < Error # :nodoc:
24
+ attr_reader :original
25
+
26
+ def initialize(original)
27
+ @original = original
28
+ super(original.message)
29
+ end
30
+ end
31
+
32
+ class_attribute :connection_value
33
+ class_attribute :tool_mapping_value
34
+ class_attribute :result_mapping_value
35
+ class_attribute :optional_value, default: false
36
+ class_attribute :error_callback_value
37
+
38
+ class << self
39
+ # Declares a static connection Hash or a block called with the current
40
+ # Tool::Binding. The Hash requires +url+ and may include
41
+ # +headers+, +timeout+, +signer+, +allow_insecure_http+, and
42
+ # +max_response_bytes+.
43
+ #
44
+ # :call-seq:
45
+ # connection() -> Hash, Proc, nil
46
+ # connection(values) -> Hash
47
+ # connection { |binding| ... } -> Proc
48
+ def connection(value = UNSET, &resolver)
49
+ return connection_value if value.equal?(UNSET) && !resolver
50
+ raise ArgumentError, "provide a connection or a block, not both" unless value.equal?(UNSET) || !resolver
51
+
52
+ configured = resolver || value
53
+ unless configured.is_a?(Hash) || configured.respond_to?(:call)
54
+ raise ArgumentError, "connection must be a hash or callable"
55
+ end
56
+
57
+ self.connection_value = configured.is_a?(Hash) ? normalize_connection(configured) : configured
58
+ end
59
+
60
+ # Maps each generated Tool class. The block receives +definition:+ and
61
+ # +binding:+ keywords. Return the configured Tool class, or nil to omit
62
+ # it. Changing Tool#tool_name does not change the operation name sent to
63
+ # the MCP server.
64
+ #
65
+ # :call-seq:
66
+ # map_tool() -> Proc, nil
67
+ # map_tool { |tool_class, definition:, binding:| ... } -> Proc
68
+ def map_tool(&mapping)
69
+ return tool_mapping_value unless mapping
70
+
71
+ self.tool_mapping_value = mapping
72
+ end
73
+
74
+ # Maps each immutable MCP::Result. The block receives +call:+ and
75
+ # +binding:+ keywords and returns any Ruby value or Tool::Result.
76
+ #
77
+ # :call-seq:
78
+ # map_result() -> Proc, nil
79
+ # map_result { |result, call:, binding:| ... } -> Proc
80
+ def map_result(&mapping)
81
+ return result_mapping_value unless mapping
82
+
83
+ self.result_mapping_value = mapping
84
+ end
85
+
86
+ # Makes expected provider and protocol discovery failures produce no
87
+ # tools. Configuration, cancellation, deadline, and callback failures
88
+ # still propagate.
89
+ #
90
+ # :call-seq:
91
+ # optional() -> true or false
92
+ # optional(value) -> true or false
93
+ def optional(value = UNSET)
94
+ return optional_value if value.equal?(UNSET)
95
+
96
+ self.optional_value = !!value
97
+ end
98
+
99
+ # Observes an expected discovery failure caught by <tt>optional true</tt>.
100
+ # Exceptions raised by this callback propagate.
101
+ #
102
+ # :call-seq:
103
+ # on_error() -> Proc, nil
104
+ # on_error { |error, binding:| ... } -> Proc
105
+ def on_error(&callback)
106
+ return error_callback_value unless callback
107
+
108
+ self.error_callback_value = callback
109
+ end
110
+
111
+ # Generates Tool classes for an Agent's current binding.
112
+ def tools(binding)
113
+ options = resolved_connection(binding)
114
+ context = binding.run.context if binding.run&.respond_to?(:context)
115
+ mapper = tool_mapping_value
116
+ result_mapper = result_mapping_value
117
+ options = connection_with_wrapped_signer(options)
118
+ Instrumentation.instrument(:mcp_discovery, toolset: toolset_name) do |telemetry|
119
+ client = Client.new(
120
+ transport: HTTPTransport.new(**options),
121
+ name: toolset_name,
122
+ tool_mapper: mapper && lambda do |tool_class, definition:, binding:|
123
+ invoke_application_callback do
124
+ mapper.call(tool_class, definition:, binding:)
125
+ end
126
+ end,
127
+ result_mapper: result_mapper && lambda do |result, call:, binding:|
128
+ result_mapper.call(result, call:, binding:)
129
+ end
130
+ )
131
+ discovered = client.tools(context:, binding:)
132
+ telemetry[:outcome] = :success
133
+ telemetry[:tool_count] = discovered.length
134
+ discovered
135
+ end
136
+ rescue CallbackFailure => error
137
+ raise error.original
138
+ rescue ProviderError, ProtocolError, ToolError => error
139
+ raise unless optional_value
140
+
141
+ error_callback_value&.call(error, binding:)
142
+ []
143
+ end
144
+
145
+ private
146
+
147
+ def resolved_connection(binding)
148
+ configured = connection_value
149
+ raise ConfigurationError, "#{toolset_name} must declare an MCP connection" unless configured
150
+
151
+ value = if configured.respond_to?(:call)
152
+ invoke_application_callback { configured.call(binding) }
153
+ else
154
+ configured
155
+ end
156
+ normalize_connection(value)
157
+ end
158
+
159
+ def normalize_connection(value)
160
+ hash = Hash.try_convert(value)
161
+ raise ConfigurationError, "#{toolset_name} connection must resolve to a hash" unless hash
162
+
163
+ normalized = hash.each_with_object({}) do |(name, child), result|
164
+ key = name.to_sym
165
+ raise ConfigurationError, "#{toolset_name} connection contains duplicate #{key}" if result.key?(key)
166
+
167
+ result[key] = child
168
+ end
169
+ unknown = normalized.keys - CONNECTION_OPTIONS
170
+ unless unknown.empty?
171
+ raise ConfigurationError, "#{toolset_name} connection contains unknown option #{unknown.first.inspect}"
172
+ end
173
+ url = normalized[:url]
174
+ raise ConfigurationError, "#{toolset_name} connection must include a URL" if url.nil? || url.to_s.empty?
175
+
176
+ normalized[:url] = String(url).freeze
177
+ normalized[:headers] = normalize_headers(normalized.fetch(:headers, {}))
178
+ normalized.freeze
179
+ rescue NoMethodError
180
+ raise ConfigurationError, "#{toolset_name} connection keys must be strings or symbols"
181
+ end
182
+
183
+ def normalize_headers(headers)
184
+ headers.each_with_object({}) do |(name, value), normalized|
185
+ normalized[String(name).dup.freeze] = String(value).dup.freeze
186
+ end.freeze
187
+ end
188
+
189
+ def connection_with_wrapped_signer(options)
190
+ signer = options[:signer]
191
+ return options unless signer
192
+
193
+ options.merge(
194
+ signer: ->(request) { invoke_application_callback { signer.call(request) } }
195
+ ).freeze
196
+ end
197
+
198
+ def invoke_application_callback
199
+ yield
200
+ rescue ProviderError, ProtocolError, ToolError => error
201
+ raise CallbackFailure.new(error)
202
+ end
203
+
204
+ def toolset_name
205
+ name || "MCP Toolset"
206
+ end
207
+ end
208
+ end
209
+ end
210
+ end
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LittleGhost
4
+ module MCP
5
+ module ImmutableValue # :nodoc:
6
+ module_function
7
+
8
+ def mapping(value, field:)
9
+ raise ProtocolError, "MCP #{field} must be an object" unless value.is_a?(Hash)
10
+
11
+ freeze_value(DataMap.new(value))
12
+ rescue ArgumentError => error
13
+ raise ProtocolError, "MCP #{field} is invalid: #{error.message}"
14
+ end
15
+
16
+ def optional_mapping(value, field:)
17
+ return if value.nil?
18
+
19
+ mapping(value, field:)
20
+ end
21
+
22
+ def array(value, field:)
23
+ raise ProtocolError, "MCP #{field} must be an array" unless value.is_a?(Array)
24
+
25
+ freeze_value(DataMap.new("items" => value).fetch("items"))
26
+ rescue ArgumentError => error
27
+ raise ProtocolError, "MCP #{field} is invalid: #{error.message}"
28
+ end
29
+
30
+ def freeze_value(value)
31
+ pending = [value]
32
+ until pending.empty?
33
+ current = pending.pop
34
+ case current
35
+ when Hash
36
+ current.each do |key, child|
37
+ key.freeze
38
+ pending << child
39
+ end
40
+ when Array
41
+ current.each { |child| pending << child }
42
+ end
43
+ current.freeze
44
+ end
45
+ value
46
+ end
47
+ end
48
+
49
+ Definition = Data.define( # :nodoc:
50
+ :source_name,
51
+ :name,
52
+ :description,
53
+ :input_schema,
54
+ :output_schema,
55
+ :annotations,
56
+ :title,
57
+ :metadata,
58
+ :raw
59
+ ) do
60
+ def initialize(source_name:, name:, description:, input_schema:, raw:, output_schema: nil,
61
+ annotations: {}, title: nil, metadata: {})
62
+ source_name = String(source_name)
63
+ name = String(name)
64
+ raise ProtocolError, "MCP tool definition must include a name" if source_name.empty?
65
+ raise ConfigurationError, "MCP tool name cannot be empty" if name.empty?
66
+
67
+ super(
68
+ source_name: source_name.freeze,
69
+ name: name.freeze,
70
+ description: String(description).freeze,
71
+ input_schema: ImmutableValue.mapping(input_schema, field: "tool inputSchema"),
72
+ output_schema: ImmutableValue.optional_mapping(output_schema, field: "tool outputSchema"),
73
+ annotations: ImmutableValue.mapping(annotations, field: "tool annotations"),
74
+ title: title.nil? ? nil : String(title).freeze,
75
+ metadata: ImmutableValue.mapping(metadata, field: "tool _meta"),
76
+ raw: ImmutableValue.mapping(raw, field: "tool definition")
77
+ )
78
+ rescue TypeError
79
+ raise ProtocolError, "MCP tool definition is invalid"
80
+ end
81
+
82
+ def [](key) = raw[key]
83
+ def fetch(...) = raw.fetch(...)
84
+ def dig(...) = raw.dig(...)
85
+ def key?(key) = raw.key?(key)
86
+ end
87
+
88
+ # Immutable server-advertised Tool metadata. +source_name+ is always the
89
+ # name sent back to the server; +name+ is the initially generated
90
+ # model-facing name and may later be customized on the generated Tool class.
91
+ class Definition < Data # :doc:
92
+ ##
93
+ # :attr_reader: source_name
94
+ # The unmodified server-advertised name used for dispatch.
95
+
96
+ ##
97
+ # :attr_reader: name
98
+ # The normalized, optionally prefixed initial model-facing name.
99
+
100
+ ##
101
+ # :attr_reader: description
102
+ # The model-facing description, including LittleGhost's fallback when the
103
+ # server omitted one.
104
+
105
+ ##
106
+ # :attr_reader: input_schema
107
+ # The deeply frozen MCP input schema.
108
+
109
+ ##
110
+ # :attr_reader: output_schema
111
+ # The optional deeply frozen MCP output schema.
112
+
113
+ ##
114
+ # :attr_reader: annotations
115
+ # Deeply frozen MCP tool annotations.
116
+
117
+ ##
118
+ # :attr_reader: title
119
+ # The optional human-readable MCP title.
120
+
121
+ ##
122
+ # :attr_reader: metadata
123
+ # Deeply frozen MCP <tt>_meta</tt> object.
124
+
125
+ ##
126
+ # :attr_reader: raw
127
+ # A deeply frozen, indifferent-access copy of the complete server
128
+ # definition object.
129
+ # Definition also delegates +[]+, +fetch+, +dig+, and +key?+ to this map.
130
+ end
131
+
132
+ Call = Data.define(:definition, :arguments, :context, :binding) do # :nodoc:
133
+ def initialize(definition:, arguments:, context: nil, binding: Tool::Binding.new)
134
+ raise ArgumentError, "definition must be a LittleGhost::MCP::Definition" unless definition.is_a?(Definition)
135
+
136
+ super(
137
+ definition:,
138
+ arguments: ImmutableValue.mapping(arguments, field: "tool call arguments"),
139
+ context:,
140
+ binding:
141
+ )
142
+ end
143
+ end
144
+
145
+ # Describes one MCP Tool invocation. It combines the server's Definition,
146
+ # the copied arguments, and the Tool::Binding used to execute the generated
147
+ # Tool.
148
+ class Call < Data # :doc:
149
+ ##
150
+ # :attr_reader: definition
151
+ # The Definition whose +source_name+ is sent to the server.
152
+
153
+ ##
154
+ # :attr_reader: arguments
155
+ # Deeply frozen, indifferent-access arguments.
156
+
157
+ ##
158
+ # :attr_reader: context
159
+ # The RunContext controlling cancellation, deadlines, and working state.
160
+
161
+ ##
162
+ # :attr_reader: binding
163
+ # The Tool::Binding for the generated Tool instance.
164
+ end
165
+
166
+ Result = Data.define(:content, :structured_content, :error, :metadata, :raw) do # :nodoc:
167
+ def initialize(content:, raw:, structured_content: nil, error: false, metadata: {})
168
+ unless error.nil? || error == true || error == false
169
+ raise ProtocolError, "MCP tool result isError must be boolean"
170
+ end
171
+
172
+ super(
173
+ content: ImmutableValue.array(content, field: "tool result content"),
174
+ structured_content: ImmutableValue.optional_mapping(
175
+ structured_content,
176
+ field: "structuredContent"
177
+ ),
178
+ error: !!error,
179
+ metadata: ImmutableValue.mapping(metadata, field: "tool result _meta"),
180
+ raw: ImmutableValue.mapping(raw, field: "tool result")
181
+ )
182
+ end
183
+
184
+ def error? = error
185
+ end
186
+
187
+ # Represents one MCP Tool result without discarding server fields. Mapping
188
+ # callbacks can inspect normalized content, structured content, metadata, or
189
+ # the complete protocol value.
190
+ class Result < Data # :doc:
191
+ ##
192
+ # :attr_reader: content
193
+ # Deeply frozen MCP content blocks.
194
+
195
+ ##
196
+ # :attr_reader: structured_content
197
+ # The optional deeply frozen structured result object.
198
+
199
+ ##
200
+ # :attr_reader: error
201
+ # Whether the server marked the result with <tt>isError</tt>.
202
+
203
+ ##
204
+ # :attr_reader: metadata
205
+ # Deeply frozen MCP result <tt>_meta</tt>.
206
+
207
+ ##
208
+ # :attr_reader: raw
209
+ # A deeply frozen copy of the complete server result object.
210
+
211
+ ##
212
+ # :method: error?
213
+ # Whether the server marked the result as an error.
214
+ end
215
+ end
216
+ end
@@ -2,4 +2,7 @@
2
2
 
3
3
  # Loads LittleGhost's optional Model Context Protocol client. Requiring
4
4
  # +little_ghost+ alone does not load its HTTP integration.
5
+ require_relative "../little_ghost"
6
+ require_relative "mcp/types"
5
7
  require_relative "mcp/client"
8
+ require_relative "mcp/toolset"
@@ -21,8 +21,8 @@ module LittleGhost
21
21
  attr_reader :role, :content, :metadata
22
22
 
23
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.
24
+ # application-defined +metadata+. Metadata becomes a frozen DataMap, so
25
+ # String and Symbol keys address the same JSON-compatible value.
26
26
  def initialize(role:, content:, metadata: {})
27
27
  @role = role.to_sym
28
28
  raise ArgumentError, "Unsupported message role: #{role.inspect}" unless ROLES.include?(@role)
@@ -35,7 +35,7 @@ module LittleGhost
35
35
  [content]
36
36
  end
37
37
  @content = blocks.map { |block| Content.normalize(block) }.freeze
38
- @metadata = metadata.freeze
38
+ @metadata = DataMap.new(metadata).freeze
39
39
  freeze
40
40
  end
41
41
 
@@ -64,7 +64,7 @@ module LittleGhost
64
64
 
65
65
  # Produces the JSON-safe message representation.
66
66
  def to_h
67
- {"role" => role.to_s, "content" => content.map(&:to_h), "metadata" => metadata}
67
+ {"role" => role.to_s, "content" => content.map(&:to_h), "metadata" => metadata.to_h}
68
68
  end
69
69
 
70
70
  # Encodes #to_h as JSON, forwarding generator +arguments+.
@@ -5,9 +5,10 @@ module LittleGhost
5
5
  # It keeps structured results and tool selection from relying on provider
6
6
  # guesswork.
7
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.
8
+ # A +nil+ +supported_parameters+ list means the parameter list is not
9
+ # restricted. Use +ModelCapabilities.unknown+ when metadata is unavailable.
10
+ # Callers must check +known?+ before treating +supports_parameter?+ as evidence
11
+ # that a provider supports a feature.
11
12
  ModelCapabilities = Data.define( # :nodoc:
12
13
  :native_structured_output,
13
14
  :tools,
@@ -59,7 +60,8 @@ module LittleGhost
59
60
 
60
61
  # Describes the optional features a model can use. Providers expose this value
61
62
  # so structured results and tool selection do not rely on provider guesswork.
62
- # A +nil+ supported-parameter list means support is unrestricted.
63
+ # A +nil+ supported-parameter list means the list itself is unrestricted.
64
+ # Check +known?+ before treating that as known provider support.
63
65
  class ModelCapabilities < Data # :doc:
64
66
  ##
65
67
  # :singleton-method: new
@@ -112,8 +114,9 @@ module LittleGhost
112
114
  # :call-seq:
113
115
  # supports_parameter?(*names) -> boolean
114
116
  #
115
- # Checks whether any supplied parameter name is supported. An unrestricted
116
- # capability set supports every name.
117
+ # Checks only the parameter-list restriction. It returns true for every name
118
+ # when the list is +nil+, including on an unknown capability value. Check
119
+ # +known?+ when the caller needs evidence of provider support.
117
120
 
118
121
  ##
119
122
  # :singleton-method: permissive
@@ -1,18 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LittleGhost
4
- # ModelRequest carries everything a provider needs for one model stream. It
5
- # keeps messages, tools, settings, structured-output requirements, and
6
- # cooperative execution controls together.
7
- #
8
- # Messages are coerced into Message objects, and required capabilities are
9
- # normalized to unique symbols. The messages, tools, settings, and capability
10
- # containers are frozen; output schema and tool choice values are retained as
11
- # supplied.
12
- #
13
- # Only those outer containers are frozen. Do not mutate retained output
14
- # schemas, tool choices, or nested settings after construction, and do not
15
- # share mutable control values across concurrent requests.
16
4
  ModelRequest = Data.define( # :nodoc:
17
5
  :messages,
18
6
  :tools,
@@ -1,9 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LittleGhost
4
- # Resolves logical profiles, canonical +provider:model-id+ targets, and inline
5
- # model mappings into executable Model objects. Subclasses may override
6
- # #resolve, #details, or #refresh! while preserving those public signatures.
4
+ # Maps an application model role to an executable Model.
5
+ #
6
+ # resolver = LittleGhost::ModelResolver.new(
7
+ # profiles: {
8
+ # customer_support: {target: "openrouter:openai/gpt-5.6-luna"}
9
+ # }
10
+ # )
11
+ # # With OPENROUTER_API_KEY set:
12
+ # model = resolver.resolve(:customer_support)
13
+ #
14
+ # A role is an application-facing name such as +:customer_support+. Callers
15
+ # may also pass a +provider:model-id+ target or an inline configuration
16
+ # mapping.
7
17
  class ModelResolver
8
18
  DEFAULT_CREDENTIALS = [ # :nodoc:
9
19
  ["LITTLEGHOST_OPENROUTER_API_KEY", "openrouter"],
@@ -30,7 +40,7 @@ module LittleGhost
30
40
  @providers = providers
31
41
  @connections = providers&.connections || {}
32
42
  @profiles = normalize_profiles(profiles || {})
33
- @default_model = default_model&.to_s || "default"
43
+ @default_model = (default_model&.to_s || "default").dup.freeze
34
44
  @provider_registry = provider_adapters.empty? ? provider_registry : ProviderRegistry.new(adapters: provider_adapters)
35
45
  @credential_resolver = credential_resolver
36
46
  configure_default_providers unless providers
@@ -254,7 +264,7 @@ module LittleGhost
254
264
 
255
265
  parent = profile["inherits"] || profile[:inherits]
256
266
  validate_role_name!(parent.to_s) if parent
257
- [role, profile.to_h]
267
+ [role, immutable_selection_value(profile.to_h)]
258
268
  end
259
269
  end
260
270
 
@@ -1,11 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LittleGhost
4
- # ModelResponse is the final result shared by every provider stream. It keeps
5
- # provider-specific response shapes out of the agent loop.
6
- #
7
- # +message+ is the assistant Message, +stop_reason+ is a normalized symbol,
8
- # and +usage+ contains provider-independent token counts.
9
4
  ModelResponse = Data.define(:message, :stop_reason, :usage, :metadata) do # :nodoc:
10
5
  # Creates an immutable response and coerces +message+ into a Message.
11
6
  def initialize(message:, stop_reason:, usage: Usage.new, metadata: {})
@@ -27,7 +22,7 @@ module LittleGhost
27
22
  # new(message:, stop_reason:, usage: Usage.new, metadata: {}) -> ModelResponse
28
23
  #
29
24
  # Coerces +message+ to Message, normalizes +stop_reason+ to a Symbol, and
30
- # freezes +metadata+.
25
+ # freezes the outer +metadata+ Hash.
31
26
 
32
27
  ##
33
28
  # :attr_reader: message
@@ -43,6 +38,7 @@ module LittleGhost
43
38
 
44
39
  ##
45
40
  # :attr_reader: metadata
46
- # Frozen provider-specific response metadata.
41
+ # Provider-specific response metadata with a frozen outer Hash. Nested
42
+ # values are retained and must not be mutated by callers.
47
43
  end
48
44
  end