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,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,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
|