llm.rb 10.0.0 → 11.0.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 +4 -4
- data/CHANGELOG.md +116 -10
- data/README.md +116 -32
- data/lib/llm/a2a/card/capabilities.rb +41 -0
- data/lib/llm/a2a/card/interface.rb +34 -0
- data/lib/llm/a2a/card/provider.rb +27 -0
- data/lib/llm/a2a/card/skill.rb +68 -0
- data/lib/llm/a2a/card.rb +144 -0
- data/lib/llm/a2a/error.rb +49 -0
- data/lib/llm/a2a/notifications.rb +53 -0
- data/lib/llm/a2a/tasks.rb +55 -0
- data/lib/llm/a2a/transport/http.rb +131 -0
- data/lib/llm/a2a.rb +452 -0
- data/lib/llm/active_record/acts_as_agent.rb +15 -9
- data/lib/llm/active_record/acts_as_llm.rb +4 -4
- data/lib/llm/agent.rb +15 -9
- data/lib/llm/buffer.rb +1 -2
- data/lib/llm/context.rb +31 -5
- data/lib/llm/file.rb +7 -0
- data/lib/llm/function.rb +1 -1
- data/lib/llm/mcp/transport/http.rb +5 -18
- data/lib/llm/mcp/transport/stdio.rb +7 -0
- data/lib/llm/mcp.rb +20 -17
- data/lib/llm/message.rb +1 -1
- data/lib/llm/object/kernel.rb +1 -1
- data/lib/llm/provider.rb +2 -9
- data/lib/llm/response.rb +1 -1
- data/lib/llm/sequel/agent.rb +14 -9
- data/lib/llm/sequel/plugin.rb +8 -7
- data/lib/llm/tool.rb +57 -27
- data/lib/llm/tracer.rb +1 -1
- data/lib/llm/transport/http.rb +1 -1
- data/lib/llm/transport/stream_decoder.rb +6 -3
- data/lib/llm/transport/utils.rb +35 -0
- data/lib/llm/transport.rb +1 -0
- data/lib/llm/utils.rb +44 -0
- data/lib/llm/version.rb +1 -1
- data/lib/llm.rb +23 -4
- data/llm.gemspec +16 -1
- metadata +26 -3
- data/lib/llm/mcp/transport/http/event_handler.rb +0 -68
data/lib/llm/a2a.rb
ADDED
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
##
|
|
4
|
+
# The {LLM::A2A} class provides access to agents that implement the
|
|
5
|
+
# Agent2Agent (A2A) Protocol. A2A defines a standard way for
|
|
6
|
+
# independent AI agents to discover each other's capabilities,
|
|
7
|
+
# negotiate interaction modalities, and collaborate on tasks.
|
|
8
|
+
#
|
|
9
|
+
# In llm.rb, {LLM::A2A} supports both HTTP+JSON/REST and JSON-RPC 2.0
|
|
10
|
+
# protocol bindings and focuses on discovering agent skills that can be
|
|
11
|
+
# used through {LLM::Context} and {LLM::Agent}.
|
|
12
|
+
#
|
|
13
|
+
# Requests can be made concurrently and responses are matched by task id.
|
|
14
|
+
#
|
|
15
|
+
# @example REST binding (default)
|
|
16
|
+
# a2a = LLM::A2A.rest(url: "https://agent.example.com")
|
|
17
|
+
# card = a2a.card
|
|
18
|
+
# puts card.skills.map(&:name)
|
|
19
|
+
# task = a2a.send_message("What is the weather in Tokyo?").task
|
|
20
|
+
# a2a.tasks.get(task.id)
|
|
21
|
+
#
|
|
22
|
+
# @example JSON-RPC binding
|
|
23
|
+
# a2a = LLM::A2A.jsonrpc(url: "https://agent.example.com")
|
|
24
|
+
#
|
|
25
|
+
# @example Using skills as tools in a context
|
|
26
|
+
# llm = LLM.openai(key: ENV["KEY"])
|
|
27
|
+
# a2a = LLM::A2A.rest(url: "https://agent.example.com")
|
|
28
|
+
# ctx = LLM::Context.new(llm, tools: a2a.skills)
|
|
29
|
+
# ctx.talk("Analyze this data using the remote agent.")
|
|
30
|
+
# ctx.talk(ctx.wait(:call)) while ctx.functions?
|
|
31
|
+
class LLM::A2A
|
|
32
|
+
require_relative "a2a/card"
|
|
33
|
+
require_relative "a2a/error"
|
|
34
|
+
require_relative "a2a/tasks"
|
|
35
|
+
require_relative "a2a/notifications"
|
|
36
|
+
require_relative "a2a/transport/http"
|
|
37
|
+
|
|
38
|
+
##
|
|
39
|
+
# @param [Symbol] binding
|
|
40
|
+
# The protocol binding to use. One of `:rest` (HTTP+JSON/REST) or
|
|
41
|
+
# `:jsonrpc` (JSON-RPC 2.0). Defaults to `:rest`.
|
|
42
|
+
# @param [Object] transport
|
|
43
|
+
# The transport used to communicate with the remote A2A agent
|
|
44
|
+
# @param [String] base_path
|
|
45
|
+
# Optional base path prefix for REST endpoints
|
|
46
|
+
# @param [String] protocol_version
|
|
47
|
+
# The expected A2A protocol version. Defaults to `"1.0"`.
|
|
48
|
+
# @return [LLM::A2A]
|
|
49
|
+
def initialize(transport:, binding: :rest, base_path: "", protocol_version: "1.0")
|
|
50
|
+
@binding = binding
|
|
51
|
+
@base_path = LLM::Utils.normalize_base_path(base_path)
|
|
52
|
+
@protocol_version = protocol_version
|
|
53
|
+
@transport = transport
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
##
|
|
57
|
+
# Builds an A2A client over HTTP.
|
|
58
|
+
# @param [String] url
|
|
59
|
+
# The base URL of the A2A agent (e.g., "https://agent.example.com")
|
|
60
|
+
# @param [Hash<String, String>] headers
|
|
61
|
+
# Extra HTTP headers to include in requests (e.g., Authorization)
|
|
62
|
+
# @param [Integer, nil] timeout
|
|
63
|
+
# The timeout in seconds for HTTP requests
|
|
64
|
+
# @param [LLM::Transport, Class, nil] transport
|
|
65
|
+
# Optional override with any {LLM::Transport} instance or subclass
|
|
66
|
+
# @param [Symbol] binding
|
|
67
|
+
# The protocol binding to use. One of `:rest` or `:jsonrpc`
|
|
68
|
+
# @param [String] base_path
|
|
69
|
+
# Optional base path prefix for REST endpoints
|
|
70
|
+
# @param [String] protocol_version
|
|
71
|
+
# The expected A2A protocol version. Defaults to `"1.0"`.
|
|
72
|
+
# @return [LLM::A2A]
|
|
73
|
+
def self.http(url:, headers: {}, timeout: 30, transport: nil, binding: :rest, base_path: "", protocol_version: "1.0")
|
|
74
|
+
new(
|
|
75
|
+
binding:,
|
|
76
|
+
base_path:,
|
|
77
|
+
protocol_version:,
|
|
78
|
+
transport: Transport::HTTP.new(
|
|
79
|
+
url:,
|
|
80
|
+
headers:,
|
|
81
|
+
timeout:,
|
|
82
|
+
transport:,
|
|
83
|
+
protocol_version:
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
##
|
|
89
|
+
# Builds an A2A client over HTTP+JSON/REST.
|
|
90
|
+
# @param [String] url
|
|
91
|
+
# @param [Hash<String, String>] headers
|
|
92
|
+
# @param [Integer, nil] timeout
|
|
93
|
+
# @param [LLM::Transport, Class, nil] transport
|
|
94
|
+
# @return [LLM::A2A]
|
|
95
|
+
def self.rest(url:, headers: {}, timeout: 30, transport: nil, base_path: "", protocol_version: "1.0")
|
|
96
|
+
http(
|
|
97
|
+
url:,
|
|
98
|
+
headers:,
|
|
99
|
+
timeout:,
|
|
100
|
+
transport:,
|
|
101
|
+
binding: :rest,
|
|
102
|
+
base_path:,
|
|
103
|
+
protocol_version:
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
##
|
|
108
|
+
# Builds an A2A client over HTTP+JSON with JSON-RPC 2.0.
|
|
109
|
+
# @param [String] url
|
|
110
|
+
# @param [Hash<String, String>] headers
|
|
111
|
+
# @param [Integer, nil] timeout
|
|
112
|
+
# @param [LLM::Transport, Class, nil] transport
|
|
113
|
+
# @return [LLM::A2A]
|
|
114
|
+
def self.jsonrpc(url:, headers: {}, timeout: 30, transport: nil, base_path: "", protocol_version: "1.0")
|
|
115
|
+
http(
|
|
116
|
+
url:,
|
|
117
|
+
headers:,
|
|
118
|
+
timeout:,
|
|
119
|
+
transport:,
|
|
120
|
+
binding: :jsonrpc,
|
|
121
|
+
base_path:,
|
|
122
|
+
protocol_version:
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
##
|
|
127
|
+
# Returns the active protocol binding.
|
|
128
|
+
# @return [Symbol]
|
|
129
|
+
attr_reader :binding
|
|
130
|
+
|
|
131
|
+
##
|
|
132
|
+
# Returns the remote agent card.
|
|
133
|
+
#
|
|
134
|
+
# The agent card is fetched from `/.well-known/agent-card.json` and
|
|
135
|
+
# cached for the lifetime of this client instance.
|
|
136
|
+
# @return [LLM::A2A::Card]
|
|
137
|
+
def card
|
|
138
|
+
return @card if defined?(@card)
|
|
139
|
+
@card = LLM::A2A::Card.new(transport.get("/.well-known/agent-card.json"))
|
|
140
|
+
end
|
|
141
|
+
alias_method :agent_card, :card
|
|
142
|
+
|
|
143
|
+
##
|
|
144
|
+
# Returns the agent's skills adapted as callable tools.
|
|
145
|
+
#
|
|
146
|
+
# Each skill in the agent card is mapped to an {LLM::Tool} subclass
|
|
147
|
+
# that wraps a {#send_message} call. When the tool is called, it
|
|
148
|
+
# sends a message to the remote agent and returns the task artifacts
|
|
149
|
+
# as the result.
|
|
150
|
+
# @return [Array<Class<LLM::Tool>>]
|
|
151
|
+
def skills
|
|
152
|
+
@skills ||= card.skills.map { LLM::Tool.a2a(self, _1) }
|
|
153
|
+
end
|
|
154
|
+
alias_method :tools, :skills
|
|
155
|
+
|
|
156
|
+
##
|
|
157
|
+
# Returns task-oriented A2A operations.
|
|
158
|
+
# @return [LLM::A2A::Tasks]
|
|
159
|
+
def tasks
|
|
160
|
+
@tasks ||= LLM::A2A::Tasks.new(self)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
##
|
|
164
|
+
# Returns push notification configuration operations.
|
|
165
|
+
# @return [LLM::A2A::Notifications]
|
|
166
|
+
def notifications
|
|
167
|
+
@notifications ||= LLM::A2A::Notifications.new(self)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
##
|
|
171
|
+
# Sends a message to the agent and returns the response.
|
|
172
|
+
# @param [String] text The message text to send
|
|
173
|
+
# @param [Hash] config
|
|
174
|
+
# Optional configuration (accepted_output_modes, return_immediately)
|
|
175
|
+
# @param [Hash, nil] metadata
|
|
176
|
+
# Optional metadata to attach to the request
|
|
177
|
+
# @return [LLM::Object] The task or message response
|
|
178
|
+
def send_message(text, configuration = {}, metadata: nil)
|
|
179
|
+
body = build_request(
|
|
180
|
+
"SendMessage",
|
|
181
|
+
message: {role: "ROLE_USER", parts: [{text:}], messageId: SecureRandom.uuid},
|
|
182
|
+
configuration:,
|
|
183
|
+
metadata:
|
|
184
|
+
)
|
|
185
|
+
execute_request(body)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
##
|
|
189
|
+
# Sends a streaming message to the agent.
|
|
190
|
+
#
|
|
191
|
+
# The block is called for each {LLM::Object} event in the stream
|
|
192
|
+
# (Task, Message, TaskStatusUpdateEvent, TaskArtifactUpdateEvent).
|
|
193
|
+
# @param [String] text The message text to send
|
|
194
|
+
# @param [Hash] config Optional configuration
|
|
195
|
+
# @yieldparam [LLM::Object] event A stream event
|
|
196
|
+
# @return [void]
|
|
197
|
+
def send_streaming_message(text, configuration = {}, &on_event)
|
|
198
|
+
body = build_request(
|
|
199
|
+
"SendStreamingMessage",
|
|
200
|
+
message: {role: "ROLE_USER", parts: [{text:}], messageId: SecureRandom.uuid},
|
|
201
|
+
configuration:
|
|
202
|
+
)
|
|
203
|
+
execute_stream(body, &on_event)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
##
|
|
207
|
+
# Gets the current state of a task.
|
|
208
|
+
# @param [String] task_id The task ID to retrieve
|
|
209
|
+
# @param [Integer, nil] history_length
|
|
210
|
+
# Optional limit on recent messages to include
|
|
211
|
+
# @return [LLM::Object]
|
|
212
|
+
def get_task(task_id, history_length: nil)
|
|
213
|
+
case @binding
|
|
214
|
+
when :rest
|
|
215
|
+
path = rest_path("/tasks/#{task_id}")
|
|
216
|
+
path = "#{path}?historyLength=#{history_length}" if history_length
|
|
217
|
+
res = transport.get(path)
|
|
218
|
+
when :jsonrpc
|
|
219
|
+
body = build_request("GetTask", id: task_id, historyLength: history_length)
|
|
220
|
+
res = transport.post("/", body)
|
|
221
|
+
else
|
|
222
|
+
raise LLM::A2A::Error, "Invalid A2A binding: #{@binding.inspect}"
|
|
223
|
+
end
|
|
224
|
+
LLM::Object.from(res)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
##
|
|
228
|
+
# Cancels a task in progress.
|
|
229
|
+
# @param [String] task_id The task ID to cancel
|
|
230
|
+
# @param [Hash, nil] metadata Optional metadata to attach to the request
|
|
231
|
+
# @return [LLM::Object]
|
|
232
|
+
def cancel_task(task_id, metadata: nil)
|
|
233
|
+
body = build_request("CancelTask", id: task_id, metadata:)
|
|
234
|
+
case @binding
|
|
235
|
+
when :rest
|
|
236
|
+
res = transport.post(rest_path("/tasks/#{task_id}:cancel"), body)
|
|
237
|
+
when :jsonrpc
|
|
238
|
+
res = transport.post("/", body)
|
|
239
|
+
else
|
|
240
|
+
raise LLM::A2A::Error, "Invalid A2A binding: #{@binding.inspect}"
|
|
241
|
+
end
|
|
242
|
+
LLM::Object.from(res)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
##
|
|
246
|
+
# Subscribes to streaming updates for an existing task.
|
|
247
|
+
# @param [String] task_id The task ID to subscribe to
|
|
248
|
+
# @yieldparam [LLM::Object] event A stream event
|
|
249
|
+
# @return [void]
|
|
250
|
+
def subscribe_to_task(task_id, &on_event)
|
|
251
|
+
case @binding
|
|
252
|
+
when :rest
|
|
253
|
+
transport.get_stream(rest_path("/tasks/#{task_id}:subscribe")) { on_event&.call(LLM::Object.from(_1)) }
|
|
254
|
+
when :jsonrpc
|
|
255
|
+
body = build_request("SubscribeToTask", id: task_id)
|
|
256
|
+
transport.post_stream("/", body) { on_event&.call(LLM::Object.from(_1)) }
|
|
257
|
+
else
|
|
258
|
+
raise LLM::A2A::Error, "Invalid A2A binding: #{@binding.inspect}"
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
##
|
|
263
|
+
# Lists tasks with optional filtering.
|
|
264
|
+
# @param [String, nil] context_id Optional context ID to filter by
|
|
265
|
+
# @param [String, nil] status Optional task state to filter by
|
|
266
|
+
# @param [Integer, nil] history_length Optional limit on recent messages to include
|
|
267
|
+
# @param [String, nil] status_timestamp_after Optional lower bound for status timestamp filtering
|
|
268
|
+
# @param [Boolean, nil] include_artifacts Whether to include task artifacts
|
|
269
|
+
# @param [Integer] page_size Maximum number of tasks to return
|
|
270
|
+
# @param [String, nil] page_token Pagination cursor
|
|
271
|
+
# @return [LLM::Object]
|
|
272
|
+
def list_tasks(context_id: nil, status: nil, history_length: nil, status_timestamp_after: nil,
|
|
273
|
+
include_artifacts: nil, page_size: 20, page_token: nil)
|
|
274
|
+
case @binding
|
|
275
|
+
when :rest
|
|
276
|
+
params = {}
|
|
277
|
+
params[:contextId] = context_id if context_id
|
|
278
|
+
params[:status] = status if status
|
|
279
|
+
params[:historyLength] = history_length if history_length
|
|
280
|
+
params[:statusTimestampAfter] = status_timestamp_after if status_timestamp_after
|
|
281
|
+
params[:includeArtifacts] = include_artifacts unless include_artifacts.nil?
|
|
282
|
+
params[:pageSize] = page_size if page_size
|
|
283
|
+
params[:pageToken] = page_token if page_token
|
|
284
|
+
query = URI.encode_www_form(params)
|
|
285
|
+
path = rest_path("/tasks")
|
|
286
|
+
path = "#{path}?#{query}" unless query.empty?
|
|
287
|
+
res = transport.get(path)
|
|
288
|
+
when :jsonrpc
|
|
289
|
+
body = build_request("ListTasks", contextId: context_id, status: status,
|
|
290
|
+
historyLength: history_length,
|
|
291
|
+
statusTimestampAfter: status_timestamp_after,
|
|
292
|
+
includeArtifacts: include_artifacts,
|
|
293
|
+
pageSize: page_size, pageToken: page_token)
|
|
294
|
+
res = transport.post("/", body)
|
|
295
|
+
else
|
|
296
|
+
raise LLM::A2A::Error, "Invalid A2A binding: #{@binding.inspect}"
|
|
297
|
+
end
|
|
298
|
+
LLM::Object.from(res)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
##
|
|
302
|
+
# Creates a push notification configuration for a task.
|
|
303
|
+
# @param [String] task_id The parent task ID
|
|
304
|
+
# @param [String] url The callback URL
|
|
305
|
+
# @param [String, nil] token Optional token to include with notifications
|
|
306
|
+
# @param [Hash, nil] authentication Optional authentication information
|
|
307
|
+
# @param [String, nil] id Optional configuration ID
|
|
308
|
+
# @return [LLM::Object]
|
|
309
|
+
def create_task_push_notification_config(task_id, url:, token: nil, authentication: nil, id: nil)
|
|
310
|
+
body = build_request("CreateTaskPushNotificationConfig", taskId: task_id, url:, token:, authentication:, id:)
|
|
311
|
+
case @binding
|
|
312
|
+
when :rest
|
|
313
|
+
res = transport.post(rest_path("/tasks/#{task_id}/pushNotificationConfigs"), body)
|
|
314
|
+
when :jsonrpc
|
|
315
|
+
res = transport.post("/", body)
|
|
316
|
+
else
|
|
317
|
+
raise LLM::A2A::Error, "Invalid A2A binding: #{@binding.inspect}"
|
|
318
|
+
end
|
|
319
|
+
LLM::Object.from(res)
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
##
|
|
323
|
+
# Retrieves a push notification configuration for a task.
|
|
324
|
+
# @param [String] task_id The parent task ID
|
|
325
|
+
# @param [String] id The configuration ID
|
|
326
|
+
# @return [LLM::Object]
|
|
327
|
+
def get_task_push_notification_config(task_id, id)
|
|
328
|
+
case @binding
|
|
329
|
+
when :rest
|
|
330
|
+
res = transport.get(rest_path("/tasks/#{task_id}/pushNotificationConfigs/#{id}"))
|
|
331
|
+
when :jsonrpc
|
|
332
|
+
body = build_request("GetTaskPushNotificationConfig", taskId: task_id, id:)
|
|
333
|
+
res = transport.post("/", body)
|
|
334
|
+
else
|
|
335
|
+
raise LLM::A2A::Error, "Invalid A2A binding: #{@binding.inspect}"
|
|
336
|
+
end
|
|
337
|
+
LLM::Object.from(res)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
##
|
|
341
|
+
# Lists push notification configurations for a task.
|
|
342
|
+
# @param [String] task_id The parent task ID
|
|
343
|
+
# @param [Integer, nil] page_size Maximum number of configurations to return
|
|
344
|
+
# @param [String, nil] page_token Pagination cursor
|
|
345
|
+
# @return [LLM::Object]
|
|
346
|
+
def list_task_push_notification_configs(task_id, page_size: nil, page_token: nil)
|
|
347
|
+
case @binding
|
|
348
|
+
when :rest
|
|
349
|
+
params = {}
|
|
350
|
+
params[:pageSize] = page_size if page_size
|
|
351
|
+
params[:pageToken] = page_token if page_token
|
|
352
|
+
query = URI.encode_www_form(params)
|
|
353
|
+
path = rest_path("/tasks/#{task_id}/pushNotificationConfigs")
|
|
354
|
+
path = "#{path}?#{query}" unless query.empty?
|
|
355
|
+
res = transport.get(path)
|
|
356
|
+
when :jsonrpc
|
|
357
|
+
body = build_request("ListTaskPushNotificationConfigs", taskId: task_id, pageSize: page_size, pageToken: page_token)
|
|
358
|
+
res = transport.post("/", body)
|
|
359
|
+
else
|
|
360
|
+
raise LLM::A2A::Error, "Invalid A2A binding: #{@binding.inspect}"
|
|
361
|
+
end
|
|
362
|
+
LLM::Object.from(res)
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
##
|
|
366
|
+
# Deletes a push notification configuration for a task.
|
|
367
|
+
# @param [String] task_id The parent task ID
|
|
368
|
+
# @param [String] id The configuration ID
|
|
369
|
+
# @return [LLM::Object]
|
|
370
|
+
def delete_task_push_notification_config(task_id, id)
|
|
371
|
+
case @binding
|
|
372
|
+
when :rest
|
|
373
|
+
res = transport.delete(rest_path("/tasks/#{task_id}/pushNotificationConfigs/#{id}"))
|
|
374
|
+
when :jsonrpc
|
|
375
|
+
body = build_request("DeleteTaskPushNotificationConfig", taskId: task_id, id:)
|
|
376
|
+
res = transport.post("/", body)
|
|
377
|
+
else
|
|
378
|
+
raise LLM::A2A::Error, "Invalid A2A binding: #{@binding.inspect}"
|
|
379
|
+
end
|
|
380
|
+
LLM::Object.from(res)
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
##
|
|
384
|
+
# Returns the authenticated extended agent card.
|
|
385
|
+
# @return [LLM::A2A::Card]
|
|
386
|
+
def extended_card
|
|
387
|
+
case @binding
|
|
388
|
+
when :rest
|
|
389
|
+
res = transport.get(rest_path("/extendedAgentCard"))
|
|
390
|
+
when :jsonrpc
|
|
391
|
+
body = build_request("GetExtendedAgentCard")
|
|
392
|
+
res = transport.post("/", body)
|
|
393
|
+
else
|
|
394
|
+
raise LLM::A2A::Error, "Invalid A2A binding: #{@binding.inspect}"
|
|
395
|
+
end
|
|
396
|
+
LLM::A2A::Card.new(res)
|
|
397
|
+
end
|
|
398
|
+
alias_method :get_extended_agent_card, :extended_card
|
|
399
|
+
|
|
400
|
+
##
|
|
401
|
+
# @return [String]
|
|
402
|
+
def inspect
|
|
403
|
+
"#<#{LLM::Utils.object_id(self)} @binding=#{@binding.inspect}>"
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
private
|
|
407
|
+
|
|
408
|
+
attr_reader :transport
|
|
409
|
+
|
|
410
|
+
def build_request(method, **params)
|
|
411
|
+
case @binding
|
|
412
|
+
when :rest
|
|
413
|
+
params
|
|
414
|
+
when :jsonrpc
|
|
415
|
+
{jsonrpc: "2.0", method:, params: params.compact, id: SecureRandom.uuid}
|
|
416
|
+
else
|
|
417
|
+
raise LLM::A2A::Error, "Invalid A2A binding: #{@binding.inspect}"
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def execute_request(body)
|
|
422
|
+
res = case @binding
|
|
423
|
+
when :rest
|
|
424
|
+
transport.post(rest_path("/message:send"), body)
|
|
425
|
+
when :jsonrpc
|
|
426
|
+
res = transport.post("/", body)
|
|
427
|
+
if res["error"]
|
|
428
|
+
raise LLM::A2A::Error.new(res["error"]["message"], res["error"]["code"])
|
|
429
|
+
end
|
|
430
|
+
res["result"] || res
|
|
431
|
+
else
|
|
432
|
+
raise LLM::A2A::Error, "Invalid A2A binding: #{@binding.inspect}"
|
|
433
|
+
end
|
|
434
|
+
LLM::Object.from(res)
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
def execute_stream(body, &on_event)
|
|
438
|
+
case @binding
|
|
439
|
+
when :rest
|
|
440
|
+
transport.post_stream(rest_path("/message:stream"), body) { on_event&.call(LLM::Object.from(_1)) }
|
|
441
|
+
when :jsonrpc
|
|
442
|
+
transport.post_stream("/", body) { on_event&.call(LLM::Object.from(_1)) }
|
|
443
|
+
else
|
|
444
|
+
raise LLM::A2A::Error, "Invalid A2A binding: #{@binding.inspect}"
|
|
445
|
+
end
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def rest_path(path)
|
|
449
|
+
return path if @base_path.empty?
|
|
450
|
+
"#{@base_path}#{path}"
|
|
451
|
+
end
|
|
452
|
+
end
|
|
@@ -11,19 +11,24 @@ module LLM::ActiveRecord
|
|
|
11
11
|
# class and forwarded to an internal agent subclass.
|
|
12
12
|
module ActsAsAgent
|
|
13
13
|
module ClassMethods
|
|
14
|
-
def model(model = nil)
|
|
15
|
-
return agent.model if model.nil?
|
|
16
|
-
agent.model(model)
|
|
14
|
+
def model(model = nil, &block)
|
|
15
|
+
return agent.model if model.nil? && !block
|
|
16
|
+
agent.model(model, &block)
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
def tools(*tools)
|
|
20
|
-
return agent.tools if tools.empty?
|
|
21
|
-
agent.tools(*tools)
|
|
19
|
+
def tools(*tools, &block)
|
|
20
|
+
return agent.tools if tools.empty? && !block
|
|
21
|
+
agent.tools(*tools, &block)
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
-
def
|
|
25
|
-
return agent.
|
|
26
|
-
agent.
|
|
24
|
+
def skills(*skills, &block)
|
|
25
|
+
return agent.skills if skills.empty? && !block
|
|
26
|
+
agent.skills(*skills, &block)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def schema(schema = nil, &block)
|
|
30
|
+
return agent.schema if schema.nil? && !block
|
|
31
|
+
agent.schema(schema, &block)
|
|
27
32
|
end
|
|
28
33
|
|
|
29
34
|
def instructions(instructions = nil)
|
|
@@ -121,6 +126,7 @@ module LLM::ActiveRecord
|
|
|
121
126
|
when :json, :jsonb then ctx.restore(data:)
|
|
122
127
|
else raise ArgumentError, "Unknown format: #{options[:format].inspect}"
|
|
123
128
|
end
|
|
129
|
+
ctx
|
|
124
130
|
end
|
|
125
131
|
end
|
|
126
132
|
end
|
|
@@ -59,12 +59,12 @@ module LLM::ActiveRecord
|
|
|
59
59
|
end
|
|
60
60
|
|
|
61
61
|
##
|
|
62
|
-
# Continues the stored context
|
|
63
|
-
# @see LLM::Context#
|
|
62
|
+
# Continues the stored context with new input and flushes it.
|
|
63
|
+
# @see LLM::Context#ask
|
|
64
64
|
# @return [LLM::Response]
|
|
65
|
-
def
|
|
65
|
+
def ask(...)
|
|
66
66
|
options = self.class.llm_plugin_options
|
|
67
|
-
ctx.
|
|
67
|
+
ctx.ask(...).tap { Utils.save!(self, ctx, options) }
|
|
68
68
|
end
|
|
69
69
|
|
|
70
70
|
##
|
data/lib/llm/agent.rb
CHANGED
|
@@ -221,9 +221,14 @@ module LLM
|
|
|
221
221
|
# response = agent.talk("Hello, what is your name?")
|
|
222
222
|
# puts response.choices[0].content
|
|
223
223
|
def talk(prompt, params = {})
|
|
224
|
-
run_loop(prompt, params)
|
|
224
|
+
run_loop(prompt, params, :talk)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
##
|
|
228
|
+
# @see LLM::Context#ask
|
|
229
|
+
def ask(prompt, params = {})
|
|
230
|
+
run_loop(prompt, params, :ask)
|
|
225
231
|
end
|
|
226
|
-
alias_method :chat, :talk
|
|
227
232
|
|
|
228
233
|
##
|
|
229
234
|
# @return [LLM::Buffer<LLM::Message>]
|
|
@@ -366,13 +371,13 @@ module LLM
|
|
|
366
371
|
##
|
|
367
372
|
# @return [String]
|
|
368
373
|
def to_json(...)
|
|
369
|
-
|
|
374
|
+
LLM.json.dump(to_h, ...)
|
|
370
375
|
end
|
|
371
376
|
|
|
372
377
|
##
|
|
373
378
|
# @return [String]
|
|
374
379
|
def inspect
|
|
375
|
-
"#<#{
|
|
380
|
+
"#<#{LLM::Utils.object_id(self)} " \
|
|
376
381
|
"@llm=#{@llm.class}, @mode=#{mode.inspect}, @messages=#{messages.inspect}>"
|
|
377
382
|
end
|
|
378
383
|
|
|
@@ -451,22 +456,23 @@ module LLM
|
|
|
451
456
|
##
|
|
452
457
|
# Runs the tool loop
|
|
453
458
|
# @api private
|
|
454
|
-
def run_loop(prompt, params)
|
|
459
|
+
def run_loop(prompt, params, target)
|
|
455
460
|
run = proc do
|
|
461
|
+
talk = @ctx.method(target)
|
|
456
462
|
max = params.key?(:tool_attempts) ? params.delete(:tool_attempts) : 25
|
|
457
463
|
max = Integer(max) if max
|
|
458
464
|
stream = params[:stream] || @ctx.params[:stream]
|
|
459
465
|
stream.extra[:concurrency] = concurrency if LLM::Stream === stream
|
|
460
|
-
res =
|
|
466
|
+
res = talk.call(apply_instructions(prompt), params)
|
|
461
467
|
while @ctx.functions?
|
|
462
468
|
if max
|
|
463
469
|
max.times do
|
|
464
470
|
break unless @ctx.functions?
|
|
465
|
-
res =
|
|
471
|
+
res = talk.call(call_functions, params)
|
|
466
472
|
end
|
|
467
|
-
res =
|
|
473
|
+
res = talk.call(@ctx.functions.map(&:rate_limit), params) if @ctx.functions?
|
|
468
474
|
else
|
|
469
|
-
res =
|
|
475
|
+
res = talk.call(call_functions, params)
|
|
470
476
|
end
|
|
471
477
|
end
|
|
472
478
|
res
|
data/lib/llm/buffer.rb
CHANGED
data/lib/llm/context.rb
CHANGED
|
@@ -201,12 +201,37 @@ module LLM
|
|
|
201
201
|
@messages.concat [res.choices[-1]]
|
|
202
202
|
res
|
|
203
203
|
end
|
|
204
|
-
|
|
204
|
+
|
|
205
|
+
##
|
|
206
|
+
# Ask a question and return the content string directly.
|
|
207
|
+
# Accepts `with:` for file attachments and a block for streaming.
|
|
208
|
+
# This interface is compatible with RubyLLM's `ask` method.
|
|
209
|
+
# @param [String] prompt
|
|
210
|
+
# @param [Hash] options
|
|
211
|
+
# @option options [String, Array<String>, nil] :with
|
|
212
|
+
# File path(s) to attach
|
|
213
|
+
# @option options [#<<, LLM::Stream, nil] :stream
|
|
214
|
+
# A stream target
|
|
215
|
+
# @yield [String] content chunks when streaming
|
|
216
|
+
# @return [LLM::Response]
|
|
217
|
+
def ask(prompt, options = {}, &block)
|
|
218
|
+
options = {with: nil, stream: nil}.merge!(options || {})
|
|
219
|
+
with, stream = options.values_at(:with, :stream)
|
|
220
|
+
prompt = with ? [prompt, [*with].map { local_file(_1) }] : prompt
|
|
221
|
+
target = if block
|
|
222
|
+
blk = block.dup
|
|
223
|
+
blk.singleton_class.alias_method(:<<, :call)
|
|
224
|
+
blk
|
|
225
|
+
else
|
|
226
|
+
stream
|
|
227
|
+
end
|
|
228
|
+
target ? talk(prompt, stream: target) : talk(prompt)
|
|
229
|
+
end
|
|
205
230
|
|
|
206
231
|
##
|
|
207
232
|
# @return [String]
|
|
208
233
|
def inspect
|
|
209
|
-
"#<#{
|
|
234
|
+
"#<#{LLM::Utils.object_id(self)} " \
|
|
210
235
|
"@llm=#{@llm.class}, @mode=#{@mode.inspect}, @params=#{@params.inspect}, " \
|
|
211
236
|
"@messages=#{@messages.inspect}>"
|
|
212
237
|
end
|
|
@@ -414,7 +439,7 @@ module LLM
|
|
|
414
439
|
##
|
|
415
440
|
# @return [String]
|
|
416
441
|
def to_json(...)
|
|
417
|
-
|
|
442
|
+
LLM.json.dump(to_h, ...)
|
|
418
443
|
end
|
|
419
444
|
|
|
420
445
|
##
|
|
@@ -475,8 +500,9 @@ module LLM
|
|
|
475
500
|
# Returns the bound stream queue, if available.
|
|
476
501
|
# @api private
|
|
477
502
|
def queue
|
|
478
|
-
|
|
479
|
-
|
|
503
|
+
[@queue, stream&.queue].compact.first
|
|
504
|
+
rescue NoMethodError
|
|
505
|
+
nil
|
|
480
506
|
end
|
|
481
507
|
|
|
482
508
|
##
|
data/lib/llm/file.rb
CHANGED
|
@@ -43,6 +43,13 @@ class LLM::File
|
|
|
43
43
|
mime_type == "application/pdf"
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
+
##
|
|
47
|
+
# @return [Boolean]
|
|
48
|
+
# Returns true if the file exists on disk
|
|
49
|
+
def exist?
|
|
50
|
+
File.exist?(path)
|
|
51
|
+
end
|
|
52
|
+
|
|
46
53
|
##
|
|
47
54
|
# @return [Integer]
|
|
48
55
|
# Returns the size of the file in bytes
|