poetry-agent 0.0.2
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/CHANGELOG.md +3 -0
- data/LICENSE.txt +21 -0
- data/README.md +51 -0
- data/app/javascript/poetry/agent/a2ui_surface_controller.js +141 -0
- data/app/javascript/poetry/agent/adapter.js +77 -0
- data/app/javascript/poetry/agent/agui_client_tool_controller.js +53 -0
- data/app/javascript/poetry/agent/index.js +41 -0
- data/app/javascript/poetry/agent/stream_actions.js +65 -0
- data/app/javascript/poetry/agent/webmcp_controller.js +248 -0
- data/app/javascript/poetry/agent/webmcp_form_controller.js +109 -0
- data/config/controllers_manifest.json +82 -0
- data/config/importmap.rb +10 -0
- data/exe/poetry-agent +28 -0
- data/lib/poetry/agent/a2ui/catalog.rb +289 -0
- data/lib/poetry/agent/a2ui/catalogs/basic.rb +460 -0
- data/lib/poetry/agent/a2ui/catalogs/native.rb +176 -0
- data/lib/poetry/agent/a2ui/checks.rb +45 -0
- data/lib/poetry/agent/a2ui/evaluator.rb +139 -0
- data/lib/poetry/agent/a2ui/expression.rb +175 -0
- data/lib/poetry/agent/a2ui/functions.rb +417 -0
- data/lib/poetry/agent/a2ui/markdown.rb +63 -0
- data/lib/poetry/agent/a2ui/pointer.rb +113 -0
- data/lib/poetry/agent/a2ui/protocol.rb +12 -0
- data/lib/poetry/agent/a2ui/renderer.rb +242 -0
- data/lib/poetry/agent/a2ui/session.rb +302 -0
- data/lib/poetry/agent/a2ui/streams.rb +82 -0
- data/lib/poetry/agent/a2ui/surface.rb +352 -0
- data/lib/poetry/agent/a2ui.rb +48 -0
- data/lib/poetry/agent/agui/client.rb +69 -0
- data/lib/poetry/agent/agui/json_patch.rb +137 -0
- data/lib/poetry/agent/agui/relay.rb +105 -0
- data/lib/poetry/agent/agui/run_input.rb +83 -0
- data/lib/poetry/agent/agui/sse.rb +97 -0
- data/lib/poetry/agent/agui/transcript.rb +540 -0
- data/lib/poetry/agent/agui/turbo_stream.rb +68 -0
- data/lib/poetry/agent/agui.rb +87 -0
- data/lib/poetry/agent/config.rb +49 -0
- data/lib/poetry/agent/engine.rb +37 -0
- data/lib/poetry/agent/mcp/bundled.rb +54 -0
- data/lib/poetry/agent/mcp/http.rb +89 -0
- data/lib/poetry/agent/mcp/server.rb +962 -0
- data/lib/poetry/agent/version.rb +8 -0
- data/lib/poetry/agent/webmcp/origin_trial.rb +49 -0
- data/lib/poetry/agent/webmcp.rb +37 -0
- data/lib/poetry/agent.rb +66 -0
- data/lib/poetry-agent.rb +4 -0
- metadata +117 -0
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Poetry
|
|
6
|
+
module Agent
|
|
7
|
+
module AGUI
|
|
8
|
+
# Folds an AG-UI event stream into what a chat page renders: the
|
|
9
|
+
# messages in order, each assistant message as Chat-shaped parts
|
|
10
|
+
# (`{kind: :text, text:}`, `{kind: :reasoning, text:}`,
|
|
11
|
+
# `{kind: :tool, name:, input:, output:, state:, tool_call_id:}`),
|
|
12
|
+
# the shared state (snapshots and JSON Patch deltas), activities,
|
|
13
|
+
# the run's status, its interrupts, and the tool calls the browser
|
|
14
|
+
# must execute before the next run.
|
|
15
|
+
#
|
|
16
|
+
# Every change bumps {#version}, and {#apply} answers the ids of the
|
|
17
|
+
# messages it touched, so a relay re-renders exactly those rows with
|
|
18
|
+
# a monotonic version the page's versioned replace honors.
|
|
19
|
+
class Transcript
|
|
20
|
+
# One message. `role` is the protocol's ("user", "assistant",
|
|
21
|
+
# "tool", "activity", ...); `parts` is the render-ready list.
|
|
22
|
+
Message = Struct.new(:id, :role, :parts, :version, keyword_init: true)
|
|
23
|
+
|
|
24
|
+
# The messages in arrival order.
|
|
25
|
+
#
|
|
26
|
+
# @return [Array<Message>]
|
|
27
|
+
attr_reader :messages
|
|
28
|
+
|
|
29
|
+
# The shared state after the last snapshot / delta.
|
|
30
|
+
#
|
|
31
|
+
# @return [Hash]
|
|
32
|
+
attr_reader :state
|
|
33
|
+
|
|
34
|
+
# Activities by message id: `{ "type" => ..., "content" => ... }`.
|
|
35
|
+
#
|
|
36
|
+
# @return [Hash{String => Hash}]
|
|
37
|
+
attr_reader :activities
|
|
38
|
+
|
|
39
|
+
# The run: `{ thread_id:, run_id:, status:, interrupts:, error:, result: }`;
|
|
40
|
+
# status is :idle, :running, :finished, :interrupted, or :error.
|
|
41
|
+
#
|
|
42
|
+
# @return [Hash]
|
|
43
|
+
attr_reader :run
|
|
44
|
+
|
|
45
|
+
# The frontend-defined tool names the browser executes.
|
|
46
|
+
#
|
|
47
|
+
# @return [Array<String>]
|
|
48
|
+
attr_reader :client_tools
|
|
49
|
+
|
|
50
|
+
# Tool calls to client tools awaiting execution:
|
|
51
|
+
# `{ tool_call_id:, name:, input:, message_id: }`.
|
|
52
|
+
#
|
|
53
|
+
# @return [Array<Hash>]
|
|
54
|
+
attr_reader :pending_client_tools
|
|
55
|
+
|
|
56
|
+
# RAW and CUSTOM events, in order.
|
|
57
|
+
#
|
|
58
|
+
# @return [Array<Hash>]
|
|
59
|
+
attr_reader :custom_events
|
|
60
|
+
|
|
61
|
+
# Event types this transcript did not understand.
|
|
62
|
+
#
|
|
63
|
+
# @return [Array<String>]
|
|
64
|
+
attr_reader :unknown_events
|
|
65
|
+
|
|
66
|
+
# A monotonic clock over every applied change.
|
|
67
|
+
#
|
|
68
|
+
# @return [Integer]
|
|
69
|
+
attr_reader :version
|
|
70
|
+
|
|
71
|
+
# @param client_tools [Array<String>] names of tools the browser executes
|
|
72
|
+
def initialize(client_tools: [])
|
|
73
|
+
@client_tools = client_tools.map(&:to_s)
|
|
74
|
+
@messages = []
|
|
75
|
+
@state = {}
|
|
76
|
+
@activities = {}
|
|
77
|
+
@run = { status: :idle, interrupts: [] }
|
|
78
|
+
@pending_client_tools = []
|
|
79
|
+
@custom_events = []
|
|
80
|
+
@unknown_events = []
|
|
81
|
+
@steps = []
|
|
82
|
+
@version = 0
|
|
83
|
+
@open_text = {}
|
|
84
|
+
@tool_parts = {}
|
|
85
|
+
@tool_args = {}
|
|
86
|
+
@current_message_id = nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Applies one event.
|
|
90
|
+
#
|
|
91
|
+
# @param event [Hash] an AG-UI event (camelCase or snake_case keys)
|
|
92
|
+
# @return [Array<String>] the ids of the messages this event changed
|
|
93
|
+
def apply(event)
|
|
94
|
+
type = AGUI.field(event, "type").to_s
|
|
95
|
+
handler = HANDLERS[type]
|
|
96
|
+
unless handler
|
|
97
|
+
@unknown_events << type
|
|
98
|
+
return []
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
Array(send(handler, event)).compact
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Applies every event of a stream.
|
|
105
|
+
#
|
|
106
|
+
# @param events [#each] event hashes
|
|
107
|
+
# @return [self]
|
|
108
|
+
def apply_all(events)
|
|
109
|
+
events.each { |event| apply(event) }
|
|
110
|
+
self
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# @param id [String]
|
|
114
|
+
# @return [Message, nil]
|
|
115
|
+
def message(id)
|
|
116
|
+
@messages.find { |message| message.id == id }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# The render-ready frame of one message.
|
|
120
|
+
#
|
|
121
|
+
# @param id [String]
|
|
122
|
+
# @return [Hash] `{ parts:, version: }`
|
|
123
|
+
def frame(id)
|
|
124
|
+
found = message(id)
|
|
125
|
+
found ? { parts: found.parts.map(&:dup), version: found.version } : { parts: [], version: 0 }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# @return [Boolean] the run ended (finished, interrupted, or errored)
|
|
129
|
+
def ended?
|
|
130
|
+
%i[finished interrupted error].include?(@run[:status])
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# @return [Boolean]
|
|
134
|
+
def interrupted?
|
|
135
|
+
@run[:status] == :interrupted
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# The open interrupts (string-keyed hashes as on the wire).
|
|
139
|
+
#
|
|
140
|
+
# @return [Array<Hash>]
|
|
141
|
+
def interrupts
|
|
142
|
+
@run[:interrupts]
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# The run error, if any: `{ message:, code: }`.
|
|
146
|
+
#
|
|
147
|
+
# @return [Hash, nil]
|
|
148
|
+
def error
|
|
149
|
+
@run[:error]
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# The messages as the next run's `RunAgentInput.messages`: user
|
|
153
|
+
# and assistant messages (assistant tool calls in the protocol's
|
|
154
|
+
# `toolCalls` shape) and a tool message for every finished tool
|
|
155
|
+
# call; reasoning and activities stay client-side, as the
|
|
156
|
+
# protocol says.
|
|
157
|
+
#
|
|
158
|
+
# @return [Array<Hash>]
|
|
159
|
+
def messages_for_input
|
|
160
|
+
@messages.flat_map do |message|
|
|
161
|
+
case message.role
|
|
162
|
+
when "user" then [{ "id" => message.id, "role" => "user", "content" => text_of(message) }]
|
|
163
|
+
when "assistant" then assistant_wire(message)
|
|
164
|
+
else []
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Marks a client tool call as executed and records its result, so
|
|
170
|
+
# the next run's input carries the tool message.
|
|
171
|
+
#
|
|
172
|
+
# @param tool_call_id [String]
|
|
173
|
+
# @param content [Object] the result (a string, or data serialized as JSON)
|
|
174
|
+
# @param error [String, nil]
|
|
175
|
+
# @return [String, nil] the id of the message that changed
|
|
176
|
+
def resolve_client_tool(tool_call_id, content, error: nil)
|
|
177
|
+
part = @tool_parts[tool_call_id]
|
|
178
|
+
@pending_client_tools.reject! { |pending| pending[:tool_call_id] == tool_call_id }
|
|
179
|
+
return nil unless part
|
|
180
|
+
|
|
181
|
+
part[:output] = error ? { "error" => error } : parse_json(content)
|
|
182
|
+
part[:state] = error ? :error : :done
|
|
183
|
+
touch(part[:message_id])
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
private
|
|
187
|
+
|
|
188
|
+
HANDLERS = {
|
|
189
|
+
"RUN_STARTED" => :run_started, "RUN_FINISHED" => :run_finished, "RUN_ERROR" => :run_error,
|
|
190
|
+
"STEP_STARTED" => :step, "STEP_FINISHED" => :step,
|
|
191
|
+
"TEXT_MESSAGE_START" => :text_start, "TEXT_MESSAGE_CONTENT" => :text_content,
|
|
192
|
+
"TEXT_MESSAGE_END" => :text_end, "TEXT_MESSAGE_CHUNK" => :text_chunk,
|
|
193
|
+
"REASONING_MESSAGE_START" => :reasoning_start, "REASONING_MESSAGE_CONTENT" => :reasoning_content,
|
|
194
|
+
"REASONING_MESSAGE_END" => :reasoning_end, "REASONING_MESSAGE_CHUNK" => :reasoning_chunk,
|
|
195
|
+
"THINKING_TEXT_MESSAGE_START" => :reasoning_start, "THINKING_TEXT_MESSAGE_CONTENT" => :reasoning_content,
|
|
196
|
+
"THINKING_TEXT_MESSAGE_END" => :reasoning_end,
|
|
197
|
+
"REASONING_START" => :noop, "REASONING_END" => :noop, "REASONING_ENCRYPTED_VALUE" => :noop,
|
|
198
|
+
"THINKING_START" => :noop, "THINKING_END" => :noop,
|
|
199
|
+
"TOOL_CALL_START" => :tool_start, "TOOL_CALL_ARGS" => :tool_args, "TOOL_CALL_END" => :tool_end,
|
|
200
|
+
"TOOL_CALL_CHUNK" => :tool_chunk, "TOOL_CALL_RESULT" => :tool_result,
|
|
201
|
+
"STATE_SNAPSHOT" => :state_snapshot, "STATE_DELTA" => :state_delta,
|
|
202
|
+
"MESSAGES_SNAPSHOT" => :messages_snapshot,
|
|
203
|
+
"ACTIVITY_SNAPSHOT" => :activity_snapshot, "ACTIVITY_DELTA" => :activity_delta,
|
|
204
|
+
"SUBAGENT_STARTED" => :noop, "SUBAGENT_FINISHED" => :noop, "SUBAGENT_ERROR" => :noop,
|
|
205
|
+
"RAW" => :custom, "CUSTOM" => :custom
|
|
206
|
+
}.freeze
|
|
207
|
+
private_constant :HANDLERS
|
|
208
|
+
|
|
209
|
+
def f(event, name) = AGUI.field(event, name)
|
|
210
|
+
|
|
211
|
+
def noop(_event) = []
|
|
212
|
+
|
|
213
|
+
def custom(event)
|
|
214
|
+
@custom_events << event
|
|
215
|
+
[]
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def step(event)
|
|
219
|
+
@steps << [f(event, "type"), f(event, "stepName")]
|
|
220
|
+
[]
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# --- run lifecycle ---
|
|
224
|
+
|
|
225
|
+
def run_started(event)
|
|
226
|
+
@run = { thread_id: f(event, "threadId"), run_id: f(event, "runId"), parent_run_id: f(event, "parentRunId"),
|
|
227
|
+
status: :running, interrupts: [], error: nil, result: nil }
|
|
228
|
+
@version += 1
|
|
229
|
+
[]
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def run_finished(event)
|
|
233
|
+
outcome = f(event, "outcome")
|
|
234
|
+
interrupts = if outcome.is_a?(Hash) && AGUI.field(outcome,
|
|
235
|
+
"type").to_s == "interrupt"
|
|
236
|
+
Array(AGUI.field(outcome,
|
|
237
|
+
"interrupts"))
|
|
238
|
+
else
|
|
239
|
+
[]
|
|
240
|
+
end
|
|
241
|
+
@run[:interrupts] = interrupts.map { |interrupt| stringify(interrupt) }
|
|
242
|
+
@run[:result] = f(event, "result")
|
|
243
|
+
@run[:status] = interrupts.any? ? :interrupted : :finished
|
|
244
|
+
@version += 1
|
|
245
|
+
close_open_texts
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def run_error(event)
|
|
249
|
+
@run[:status] = :error
|
|
250
|
+
@run[:error] = { message: f(event, "message").to_s, code: f(event, "code") }
|
|
251
|
+
@version += 1
|
|
252
|
+
close_open_texts
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# --- text and reasoning ---
|
|
256
|
+
|
|
257
|
+
def text_start(event) = start_part(f(event, "messageId"), f(event, "role") || "assistant", :text)
|
|
258
|
+
def text_content(event) = append_part(f(event, "messageId"), :text, f(event, "delta"))
|
|
259
|
+
def text_end(event) = end_part(f(event, "messageId"), :text)
|
|
260
|
+
def reasoning_start(event) = start_part(f(event, "messageId"), "assistant", :reasoning)
|
|
261
|
+
def reasoning_content(event) = append_part(f(event, "messageId"), :reasoning, f(event, "delta"))
|
|
262
|
+
def reasoning_end(event) = end_part(f(event, "messageId"), :reasoning)
|
|
263
|
+
|
|
264
|
+
def text_chunk(event) = chunk(event, :text, f(event, "role") || "assistant")
|
|
265
|
+
def reasoning_chunk(event) = chunk(event, :reasoning, "assistant")
|
|
266
|
+
|
|
267
|
+
def chunk(event, kind, role)
|
|
268
|
+
id = f(event, "messageId") || @current_message_id
|
|
269
|
+
return [] unless id
|
|
270
|
+
|
|
271
|
+
changed = @open_text.key?([id, kind]) ? [] : start_part(id, role, kind)
|
|
272
|
+
delta = f(event, "delta")
|
|
273
|
+
changed |= append_part(id, kind, delta) if delta && !delta.empty?
|
|
274
|
+
changed
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def start_part(id, role, kind)
|
|
278
|
+
return [] unless id
|
|
279
|
+
|
|
280
|
+
message = ensure_message(id, role)
|
|
281
|
+
part = { kind: kind, text: +"" }
|
|
282
|
+
message.parts << part
|
|
283
|
+
@open_text[[id, kind]] = part
|
|
284
|
+
@current_message_id = id
|
|
285
|
+
[touch(id)]
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def append_part(id, kind, delta)
|
|
289
|
+
id ||= @current_message_id
|
|
290
|
+
return [] unless id && delta
|
|
291
|
+
|
|
292
|
+
part = @open_text[[id, kind]]
|
|
293
|
+
unless part
|
|
294
|
+
ensure_message(id, "assistant")
|
|
295
|
+
part = { kind: kind, text: +"" }
|
|
296
|
+
message(id).parts << part
|
|
297
|
+
@open_text[[id, kind]] = part
|
|
298
|
+
end
|
|
299
|
+
part[:text] = part[:text] + delta.to_s
|
|
300
|
+
[touch(id)]
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def end_part(id, kind)
|
|
304
|
+
@open_text.delete([id || @current_message_id, kind])
|
|
305
|
+
[]
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# A run's end closes streaming text and settles chunked tool calls
|
|
309
|
+
# that never saw an END event.
|
|
310
|
+
def close_open_texts
|
|
311
|
+
@open_text.clear
|
|
312
|
+
@tool_args.each_key do |tool_call_id|
|
|
313
|
+
part = @tool_parts[tool_call_id]
|
|
314
|
+
next unless part && part[:input].nil?
|
|
315
|
+
|
|
316
|
+
part[:input] = parse_json(@tool_args[tool_call_id].to_s)
|
|
317
|
+
end
|
|
318
|
+
[]
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# --- tool calls ---
|
|
322
|
+
|
|
323
|
+
def tool_start(event)
|
|
324
|
+
tool_call_id = f(event, "toolCallId").to_s
|
|
325
|
+
name = f(event, "toolCallName").to_s
|
|
326
|
+
message_id = f(event, "parentMessageId") || @current_message_id || "message-#{tool_call_id}"
|
|
327
|
+
ensure_message(message_id, "assistant")
|
|
328
|
+
part = { kind: :tool, name: name, input: nil, output: nil, state: :loading,
|
|
329
|
+
tool_call_id: tool_call_id, message_id: message_id }
|
|
330
|
+
message(message_id).parts << part
|
|
331
|
+
@tool_parts[tool_call_id] = part
|
|
332
|
+
@tool_args[tool_call_id] = +""
|
|
333
|
+
@current_message_id = message_id
|
|
334
|
+
[touch(message_id)]
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def tool_args(event)
|
|
338
|
+
tool_call_id = f(event, "toolCallId").to_s
|
|
339
|
+
part = @tool_parts[tool_call_id]
|
|
340
|
+
return [] unless part
|
|
341
|
+
|
|
342
|
+
@tool_args[tool_call_id] << f(event, "delta").to_s
|
|
343
|
+
[touch(part[:message_id])]
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def tool_end(event)
|
|
347
|
+
tool_call_id = f(event, "toolCallId").to_s
|
|
348
|
+
part = @tool_parts[tool_call_id]
|
|
349
|
+
return [] unless part
|
|
350
|
+
|
|
351
|
+
part[:input] = parse_json(@tool_args.delete(tool_call_id).to_s)
|
|
352
|
+
if @client_tools.include?(part[:name])
|
|
353
|
+
part[:state] = :awaiting_client
|
|
354
|
+
@pending_client_tools << { tool_call_id: tool_call_id, name: part[:name], input: part[:input],
|
|
355
|
+
message_id: part[:message_id] }
|
|
356
|
+
end
|
|
357
|
+
[touch(part[:message_id])]
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def tool_chunk(event)
|
|
361
|
+
tool_call_id = f(event, "toolCallId")
|
|
362
|
+
changed = []
|
|
363
|
+
changed |= tool_start(event) if tool_call_id && !@tool_parts.key?(tool_call_id.to_s)
|
|
364
|
+
delta = f(event, "delta")
|
|
365
|
+
id = (tool_call_id || @tool_parts.keys.last).to_s
|
|
366
|
+
changed |= tool_args({ "toolCallId" => id, "delta" => delta }) if delta
|
|
367
|
+
# A chunked call has no END event: its input is whatever has parsed so far.
|
|
368
|
+
part = @tool_parts[id]
|
|
369
|
+
if part && (parsed = parse_json(@tool_args[id].to_s)).is_a?(Hash)
|
|
370
|
+
part[:input] = parsed
|
|
371
|
+
end
|
|
372
|
+
changed
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def tool_result(event)
|
|
376
|
+
tool_call_id = f(event, "toolCallId").to_s
|
|
377
|
+
part = @tool_parts[tool_call_id]
|
|
378
|
+
return [] unless part
|
|
379
|
+
|
|
380
|
+
part[:input] ||= parse_json(@tool_args.delete(tool_call_id).to_s) if @tool_args.key?(tool_call_id)
|
|
381
|
+
part[:output] = parse_json(f(event, "content"))
|
|
382
|
+
part[:state] = :done
|
|
383
|
+
@pending_client_tools.reject! { |pending| pending[:tool_call_id] == tool_call_id }
|
|
384
|
+
[touch(part[:message_id])]
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# --- state, messages, activities ---
|
|
388
|
+
|
|
389
|
+
def state_snapshot(event)
|
|
390
|
+
@state = JsonPatch.deep_copy(f(event, "snapshot") || {})
|
|
391
|
+
@version += 1
|
|
392
|
+
[]
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def state_delta(event)
|
|
396
|
+
@state = JsonPatch.apply(@state, f(event, "delta") || [])
|
|
397
|
+
@version += 1
|
|
398
|
+
[]
|
|
399
|
+
rescue JsonPatch::Error => e
|
|
400
|
+
@unknown_events << "STATE_DELTA(#{e.message})"
|
|
401
|
+
[]
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def messages_snapshot(event)
|
|
405
|
+
rebuilt = Array(f(event, "messages")).filter_map { |message| snapshot_message(stringify(message)) }
|
|
406
|
+
@messages = rebuilt
|
|
407
|
+
@open_text.clear
|
|
408
|
+
@version += 1
|
|
409
|
+
@messages.each { |message| message.version = @version }
|
|
410
|
+
@messages.map(&:id)
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
# One snapshot message as a Message - or nil for a tool result, which
|
|
414
|
+
# lands on its call's part instead.
|
|
415
|
+
def snapshot_message(wire)
|
|
416
|
+
id = wire["id"].to_s
|
|
417
|
+
case wire["role"].to_s
|
|
418
|
+
when "user", "system", "developer"
|
|
419
|
+
Message.new(id: id, role: wire["role"].to_s, parts: [{ kind: :text, text: wire["content"].to_s }],
|
|
420
|
+
version: 0)
|
|
421
|
+
when "assistant" then snapshot_assistant(wire)
|
|
422
|
+
when "tool" then snapshot_tool_result(wire)
|
|
423
|
+
when "activity"
|
|
424
|
+
part = { kind: :activity, activity_type: wire["activityType"], content: wire["content"] }
|
|
425
|
+
Message.new(id: id, role: "activity", parts: [part], version: 0)
|
|
426
|
+
when "reasoning"
|
|
427
|
+
Message.new(id: id, role: "assistant", parts: [{ kind: :reasoning, text: wire["content"].to_s }],
|
|
428
|
+
version: 0)
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def snapshot_assistant(wire)
|
|
433
|
+
parts = []
|
|
434
|
+
parts << { kind: :text, text: wire["content"] } if wire["content"].is_a?(String) && !wire["content"].empty?
|
|
435
|
+
Array(wire["toolCalls"]).each do |call|
|
|
436
|
+
function = call["function"] || {}
|
|
437
|
+
part = { kind: :tool, name: function["name"].to_s, input: parse_json(function["arguments"]),
|
|
438
|
+
output: nil, state: :loading, tool_call_id: call["id"].to_s, message_id: wire["id"].to_s }
|
|
439
|
+
parts << part
|
|
440
|
+
@tool_parts[part[:tool_call_id]] = part
|
|
441
|
+
end
|
|
442
|
+
Message.new(id: wire["id"].to_s, role: "assistant", parts: parts, version: 0)
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
def snapshot_tool_result(wire)
|
|
446
|
+
part = @tool_parts[wire["toolCallId"].to_s]
|
|
447
|
+
return nil unless part
|
|
448
|
+
|
|
449
|
+
part[:output] = wire["error"] ? { "error" => wire["error"] } : parse_json(wire["content"])
|
|
450
|
+
part[:state] = wire["error"] ? :error : :done
|
|
451
|
+
nil
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
def activity_snapshot(event)
|
|
455
|
+
id = f(event, "messageId").to_s
|
|
456
|
+
replace = f(event, "replace")
|
|
457
|
+
return [] if replace == false && @activities.key?(id)
|
|
458
|
+
|
|
459
|
+
@activities[id] =
|
|
460
|
+
{ "type" => f(event, "activityType"), "content" => JsonPatch.deep_copy(f(event, "content")) }
|
|
461
|
+
message = ensure_message(id, "activity")
|
|
462
|
+
message.parts = [{ kind: :activity, activity_type: @activities[id]["type"],
|
|
463
|
+
content: @activities[id]["content"] }]
|
|
464
|
+
[touch(id)]
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def activity_delta(event)
|
|
468
|
+
id = f(event, "messageId").to_s
|
|
469
|
+
activity = @activities[id]
|
|
470
|
+
return [] unless activity
|
|
471
|
+
|
|
472
|
+
activity["content"] = JsonPatch.apply(activity["content"], f(event, "patch") || [])
|
|
473
|
+
message(id).parts = [{ kind: :activity, activity_type: activity["type"], content: activity["content"] }]
|
|
474
|
+
[touch(id)]
|
|
475
|
+
rescue JsonPatch::Error => e
|
|
476
|
+
@unknown_events << "ACTIVITY_DELTA(#{e.message})"
|
|
477
|
+
[]
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
# --- helpers ---
|
|
481
|
+
|
|
482
|
+
def ensure_message(id, role)
|
|
483
|
+
id = id.to_s
|
|
484
|
+
message(id) || Message.new(id: id, role: role.to_s, parts: [], version: 0).tap do |created|
|
|
485
|
+
@messages << created
|
|
486
|
+
end
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
def touch(id)
|
|
490
|
+
@version += 1
|
|
491
|
+
found = message(id)
|
|
492
|
+
found.version = @version if found
|
|
493
|
+
id
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
def text_of(message)
|
|
497
|
+
message.parts.select { |part| part[:kind] == :text }.map { |part| part[:text] }.join
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
def assistant_wire(message)
|
|
501
|
+
wire = { "id" => message.id, "role" => "assistant", "content" => text_of(message) }
|
|
502
|
+
calls = message.parts.select { |part| part[:kind] == :tool }
|
|
503
|
+
if calls.any?
|
|
504
|
+
wire["toolCalls"] = calls.map do |part|
|
|
505
|
+
{ "id" => part[:tool_call_id], "type" => "function",
|
|
506
|
+
"function" => { "name" => part[:name], "arguments" => JSON.generate(part[:input] || {}) } }
|
|
507
|
+
end
|
|
508
|
+
end
|
|
509
|
+
results = calls.reject { |part| part[:output].nil? }.map do |part|
|
|
510
|
+
error = part[:output].is_a?(Hash) ? part[:output]["error"] : nil
|
|
511
|
+
RunInput.tool_message(part[:tool_call_id], error || as_text(part[:output]),
|
|
512
|
+
error: error, id: "#{part[:tool_call_id]}-result")
|
|
513
|
+
end
|
|
514
|
+
[wire, *results]
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
def as_text(value)
|
|
518
|
+
value.is_a?(String) ? value : JSON.generate(value)
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
def parse_json(text)
|
|
522
|
+
return text unless text.is_a?(String)
|
|
523
|
+
return text if text.empty?
|
|
524
|
+
|
|
525
|
+
JSON.parse(text)
|
|
526
|
+
rescue JSON::ParserError
|
|
527
|
+
text
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
def stringify(value)
|
|
531
|
+
case value
|
|
532
|
+
when Hash then value.to_h { |key, inner| [key.to_s, stringify(inner)] }
|
|
533
|
+
when Array then value.map { |inner| stringify(inner) }
|
|
534
|
+
else value
|
|
535
|
+
end
|
|
536
|
+
end
|
|
537
|
+
end
|
|
538
|
+
end
|
|
539
|
+
end
|
|
540
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
|
|
5
|
+
module Poetry
|
|
6
|
+
module Agent
|
|
7
|
+
module AGUI
|
|
8
|
+
# Turbo Stream builders for the relay: plain strings, no view
|
|
9
|
+
# context needed. `vreplace` is the versioned replace the runtime
|
|
10
|
+
# installs on Turbo (`registerPoetryAgent`) - it applies a frame
|
|
11
|
+
# only when its `data-version` is newer than the row's, so an
|
|
12
|
+
# out-of-order delivery can never paint an older state over a
|
|
13
|
+
# newer one.
|
|
14
|
+
module TurboStream
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# @param action [String] a Turbo Stream action (`append`, `replace`, `vreplace`, `remove`, ...)
|
|
18
|
+
# @param target [String] the target element id
|
|
19
|
+
# @param html [String, nil] the template content (already rendered, trusted)
|
|
20
|
+
# @param method [String, nil] Turbo's `method` attribute (`"morph"` morphs instead of swapping)
|
|
21
|
+
# @return [String]
|
|
22
|
+
def build(action, target, html = nil, method: nil)
|
|
23
|
+
attributes = %(action="#{escape(action)}" target="#{escape(target)}")
|
|
24
|
+
attributes += %( method="#{escape(method)}") if method
|
|
25
|
+
open = "<turbo-stream #{attributes}>"
|
|
26
|
+
return "#{open}</turbo-stream>" if html.nil?
|
|
27
|
+
|
|
28
|
+
"#{open}<template>#{html}</template></turbo-stream>"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @param target [String]
|
|
32
|
+
# @param html [String]
|
|
33
|
+
# @param morph [Boolean] morph the target (Turbo's idiomorph) instead of swapping it, so
|
|
34
|
+
# local state - typed text, a selected tab, an open dialog - survives the update
|
|
35
|
+
# @return [String]
|
|
36
|
+
def vreplace(target, html, morph: false) = build("vreplace", target, html, method: morph ? "morph" : nil)
|
|
37
|
+
|
|
38
|
+
# @param target [String]
|
|
39
|
+
# @param html [String]
|
|
40
|
+
# @return [String]
|
|
41
|
+
def append(target, html) = build("append", target, html)
|
|
42
|
+
|
|
43
|
+
# @param target [String]
|
|
44
|
+
# @param html [String]
|
|
45
|
+
# @return [String]
|
|
46
|
+
def replace(target, html) = build("replace", target, html)
|
|
47
|
+
|
|
48
|
+
# @param target [String]
|
|
49
|
+
# @return [String]
|
|
50
|
+
def remove(target) = build("remove", target)
|
|
51
|
+
|
|
52
|
+
# One SSE frame carrying the streams (newlines folded, as Turbo's
|
|
53
|
+
# stream source expects one `data:` line).
|
|
54
|
+
#
|
|
55
|
+
# @param html [String]
|
|
56
|
+
# @return [String]
|
|
57
|
+
def sse(html)
|
|
58
|
+
"data: #{html.tr("\n", " ")}\n\n"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @api private
|
|
62
|
+
def escape(value)
|
|
63
|
+
CGI.escapeHTML(value.to_s)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "agui/json_patch"
|
|
4
|
+
require_relative "agui/sse"
|
|
5
|
+
require_relative "agui/run_input"
|
|
6
|
+
require_relative "agui/client"
|
|
7
|
+
require_relative "agui/transcript"
|
|
8
|
+
require_relative "agui/turbo_stream"
|
|
9
|
+
require_relative "agui/relay"
|
|
10
|
+
|
|
11
|
+
module Poetry
|
|
12
|
+
module Agent
|
|
13
|
+
# The AG-UI surface: a Rails-side CLIENT of the Agent-User Interaction
|
|
14
|
+
# protocol. An agent backend (any AG-UI integration, or a Ruby server)
|
|
15
|
+
# streams events - text deltas, tool calls, state, activities, run
|
|
16
|
+
# lifecycle, interrupts - and this module turns that stream into
|
|
17
|
+
# server-rendered chat frames a Hotwire page updates through Turbo
|
|
18
|
+
# Streams, the same pipeline the chat replay rig proves.
|
|
19
|
+
#
|
|
20
|
+
# The pieces, each usable alone:
|
|
21
|
+
#
|
|
22
|
+
# - {SSE} parses `text/event-stream` chunks into event hashes.
|
|
23
|
+
# - {Client} POSTs a run to an AG-UI endpoint and yields its events.
|
|
24
|
+
# - {RunInput} builds the `RunAgentInput` wire hash, and
|
|
25
|
+
# {.tool_descriptor} advertises a rendered component's declared
|
|
26
|
+
# tools as frontend-defined tools the browser executes.
|
|
27
|
+
# - {Transcript} folds events into messages (Chat-shaped parts),
|
|
28
|
+
# shared state (JSON Patch), activities, the run status, pending
|
|
29
|
+
# client tools, and interrupts.
|
|
30
|
+
# - {Relay} renders each change as a versioned Turbo Stream through a
|
|
31
|
+
# host-supplied row renderer, plus the client-tool bridge element
|
|
32
|
+
# the `poetry--agent--agui-client-tool` controller executes.
|
|
33
|
+
#
|
|
34
|
+
# Nothing here calls a model: the agent is whatever the host points
|
|
35
|
+
# the client at.
|
|
36
|
+
module AGUI
|
|
37
|
+
# The AG-UI event types this transcript understands (the wire
|
|
38
|
+
# strings; deprecated THINKING_* aliases included).
|
|
39
|
+
EVENT_TYPES = %w[
|
|
40
|
+
RUN_STARTED RUN_FINISHED RUN_ERROR STEP_STARTED STEP_FINISHED
|
|
41
|
+
TEXT_MESSAGE_START TEXT_MESSAGE_CONTENT TEXT_MESSAGE_END TEXT_MESSAGE_CHUNK
|
|
42
|
+
REASONING_START REASONING_MESSAGE_START REASONING_MESSAGE_CONTENT REASONING_MESSAGE_END
|
|
43
|
+
REASONING_MESSAGE_CHUNK REASONING_END REASONING_ENCRYPTED_VALUE
|
|
44
|
+
THINKING_START THINKING_TEXT_MESSAGE_START THINKING_TEXT_MESSAGE_CONTENT THINKING_TEXT_MESSAGE_END THINKING_END
|
|
45
|
+
TOOL_CALL_START TOOL_CALL_ARGS TOOL_CALL_END TOOL_CALL_CHUNK TOOL_CALL_RESULT
|
|
46
|
+
STATE_SNAPSHOT STATE_DELTA MESSAGES_SNAPSHOT ACTIVITY_SNAPSHOT ACTIVITY_DELTA
|
|
47
|
+
SUBAGENT_STARTED SUBAGENT_FINISHED SUBAGENT_ERROR RAW CUSTOM
|
|
48
|
+
].freeze
|
|
49
|
+
|
|
50
|
+
# The frontend-defined tool descriptor for one of a rendered
|
|
51
|
+
# component's declared tools: the MCP `Tool` shape the registry
|
|
52
|
+
# projects, renamed to AG-UI's `parameters` and prefixed with the
|
|
53
|
+
# instance name exactly as the WebMCP registrar registers it, so a
|
|
54
|
+
# call the agent makes is executable in the browser by name.
|
|
55
|
+
#
|
|
56
|
+
# @param instance [String] the `webmcp:` instance name
|
|
57
|
+
# @param definition [Hash] one entry of `Component#webmcp_tools`
|
|
58
|
+
# @return [Hash] `{ "name", "description", "parameters" }`
|
|
59
|
+
# @example
|
|
60
|
+
# Poetry::Agent::AGUI.tool_descriptor("sections", tabs.webmcp_tools.first)
|
|
61
|
+
# # => { "name" => "poetry.sections.set_value", "description" => "...", "parameters" => {...} }
|
|
62
|
+
def self.tool_descriptor(instance, definition)
|
|
63
|
+
{
|
|
64
|
+
"name" => "poetry.#{instance}.#{definition["name"]}",
|
|
65
|
+
"description" => definition["description"],
|
|
66
|
+
"parameters" => definition["inputSchema"] || { "type" => "object", "properties" => {} }
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Reads a wire field from an event or message that may arrive
|
|
71
|
+
# camelCased (the protocol) or snake_cased (a Ruby producer).
|
|
72
|
+
#
|
|
73
|
+
# @param hash [Hash]
|
|
74
|
+
# @param name [String] the camelCase name
|
|
75
|
+
# @return [Object, nil]
|
|
76
|
+
def self.field(hash, name)
|
|
77
|
+
return nil unless hash.is_a?(Hash)
|
|
78
|
+
|
|
79
|
+
snake = name.gsub(/([A-Z])/) { "_#{Regexp.last_match(1).downcase}" }
|
|
80
|
+
[name, snake, name.to_sym, snake.to_sym].each do |key|
|
|
81
|
+
return hash[key] if hash.key?(key)
|
|
82
|
+
end
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|